Initial commit — knowledge base migrada do dev local

This commit is contained in:
Rodolpho Lopes 2026-05-26 15:11:41 +00:00
commit 2b10435643
26 changed files with 2864 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.DS_Store
*.swp
*.tmp
*.bak

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# Base de Conhecimento para Agentes de IA
Esta base foi projetada para leitura eficiente por agentes e humanos.
## Arquitetura recomendada
- `index.json`: catalogo central para busca semantica rapida por agente.
- `records/`: um registro por arquivo Markdown, com metadados em front matter YAML.
- `schemas/`: contrato JSON Schema para validar metadados e manter consistencia.
## Por que esse formato e eficiente
- **JSON para roteamento**: agentes localizam o registro certo sem ler toda a base.
- **Markdown para contexto**: explica causa, impacto e procedimento com baixa ambiguidade.
- **Metadados padronizados**: permite filtro por `tags`, `severity`, `domain` e `status`.
- **Versionamento simples**: cada incidente vira um arquivo independente e auditavel no Git.
## Convencao de registros
- ID: `KB-<DOMINIO>-NNN` (ex.: `KB-INFRA-001`)
- Nome de arquivo: `<ID>-<slug>.md`
- Campos obrigatorios de metadados:
- `id`
- `title`
- `domain`
- `tags`
- `status`
- `severity`
- `created_at`
- `updated_at`
- `applies_to`
## Fluxo de atualizacao
1. Criar novo registro em `records/<dominio>/`.
2. Atualizar `index.json` com resumo e caminho.
3. Validar aderencia ao schema em `schemas/`.
4. Referenciar o registro em documentacao operacional quando necessario.
## Validacao automatica
Use:
```bash
make kb-check
make kb-fix
```
A verificacao valida:
- metadados obrigatorios conforme schema;
- padrao de ID de registro;
- sincronizacao entre `index.json` e arquivos em `records/`.
`make kb-fix` faz ajustes automaticos no `index.json`:
- inclui registros faltantes;
- remove entradas orfas;
- sincroniza campos principais;
- atualiza `last_updated`.

389
index.json Normal file
View file

@ -0,0 +1,389 @@
{
"version": "1.0.0",
"last_updated": "2026-05-21",
"records": [
{
"id": "KB-INFRA-001",
"title": "Caminho correto de plugins no container GLPI oficial",
"domain": "infrastructure",
"tags": [
"glpi",
"docker",
"plugins",
"mount",
"orbstack"
],
"status": "active",
"severity": "high",
"path": "records/infrastructure/KB-INFRA-001-glpi-plugin-path.md",
"summary": "id: KB-INFRA-001"
},
{
"id": "KB-INFRA-002",
"title": "Icone de plugin GLPI 11 requer logo.png PNG na raiz do diretorio",
"domain": "infrastructure",
"tags": [
"glpi",
"glpi11",
"plugin",
"icon",
"logo",
"marketplace"
],
"status": "active",
"severity": "low",
"path": "records/infrastructure/KB-INFRA-002-plugin-icon-logo-png.md",
"summary": "id: KB-INFRA-002"
},
{
"id": "KB-PLUGIN-001",
"title": "Instalacao de plugin GLPI requer callbacks install/uninstall",
"domain": "plugin-dev",
"tags": [
"glpi",
"plugin",
"install",
"setup.php",
"hooks"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-001-install-hooks-required.md",
"summary": "id: KB-PLUGIN-001"
},
{
"id": "KB-PLUGIN-002",
"title": "getCategory() deve ser sobrescrito em QuestionType de plugin GLPI 11",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"form-builder",
"question-type",
"javascript",
"plugin"
],
"status": "active",
"severity": "critical",
"path": "records/plugin-dev/KB-PLUGIN-002-questiontype-getcategory-override.md",
"summary": "id: KB-PLUGIN-002"
},
{
"id": "KB-PLUGIN-003",
"title": "JSON em <script type=\"application/json\"> nao deve usar htmlspecialchars",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"form-builder",
"javascript",
"json",
"xss",
"php"
],
"status": "active",
"severity": "critical",
"path": "records/plugin-dev/KB-PLUGIN-003-json-script-tag-no-htmlspecialchars.md",
"summary": "id: KB-PLUGIN-003"
},
{
"id": "KB-PLUGIN-004",
"title": "Chave do hook ADD_JAVASCRIPT deve ser o plugin key exato",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"hooks",
"javascript",
"setup.php",
"plugin-key"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-004-plugin-hook-key-must-match-plugin-key.md",
"summary": "id: KB-PLUGIN-004"
},
{
"id": "KB-PLUGIN-005",
"title": "ITILCategoryFieldStrategy LAST_VALID_ANSWER ignora subclasses de QuestionTypeItemDropdown",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"form-builder",
"form-destination",
"itil-category",
"answer-pipeline",
"question-type"
],
"status": "active",
"severity": "critical",
"path": "records/plugin-dev/KB-PLUGIN-005-last-valid-answer-exact-class-match.md",
"summary": "id: KB-PLUGIN-005"
},
{
"id": "KB-PLUGIN-006",
"title": "Procedimento completo de renomeacao de plugin GLPI",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"plugin",
"rename",
"refactor",
"namespace",
"database"
],
"status": "active",
"severity": "medium",
"path": "records/plugin-dev/KB-PLUGIN-006-plugin-rename-full-procedure.md",
"summary": "id: KB-PLUGIN-006"
},
{
"id": "KB-PLUGIN-007",
"title": "GLPI 11 marca QuestionTypeItemDropdown como final",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"form-builder",
"question-type",
"fatal-error"
],
"status": "active",
"severity": "critical",
"path": "records/plugin-dev/KB-PLUGIN-007-questiontypeitemdropdown-final.md",
"summary": "id: KB-PLUGIN-007"
},
{
"id": "KB-PLUGIN-008",
"title": "Interfaces de Validacao Customizada no Form Builder do GLPI 11",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"form-builder",
"validation",
"javascript"
],
"status": "active",
"severity": "medium",
"path": "records/plugin-dev/KB-PLUGIN-008-questiontype-validation-interfaces.md",
"summary": "id: KB-PLUGIN-008"
},
{
"id": "KB-PLUGIN-009",
"title": "Validacao mandatory ignora respostas que sao arrays nao-vazios com chaves em branco",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"form-builder",
"validation",
"answers-handler"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-009-transformconditionvalue-empty-array.md",
"summary": "id: KB-PLUGIN-009"
},
{
"id": "KB-PLUGIN-010",
"title": "Padrao correto para chamadas AJAX autenticadas em plugins GLPI 11",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"ajax",
"csrf",
"security",
"symfony",
"fetch",
"plugin"
],
"status": "active",
"severity": "critical",
"path": "records/plugin-dev/KB-PLUGIN-010-ajax-csrf-x-glpi-csrf-token.md",
"summary": "id: KB-PLUGIN-010"
},
{
"id": "KB-PLUGIN-011",
"title": "\"Procedimento de Release do Plugin Mindplace\"",
"domain": "plugin-dev",
"tags": [
"github",
"release",
"secrets"
],
"status": "active",
"severity": "medium",
"path": "records/plugin-dev/KB-PLUGIN-011-mindplace-release-zip-procedure.md",
"summary": "id: KB-PLUGIN-011"
},
{
"id": "KB-PLUGIN-012",
"title": "Estendendo o sistema de Tiles do Helpdesk no GLPI 11 — padrões e armadilhas",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"helpdesk",
"tiles",
"dom-manipulation",
"mutation-observer",
"twig",
"singleton",
"plugin"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-012-helpdesk-tiles-extension-patterns.md",
"summary": "id: KB-PLUGIN-012"
},
{
"id": "KB-PLUGIN-013",
"title": "Runbook — Publicação de plugin no Mindplace (procedimento padrão)",
"domain": "plugin-dev",
"tags": [
"mindplace",
"runbook",
"publish",
"license",
"forgejo",
"release",
"kill-switch",
"workflow",
"automation",
"script",
"bash"
],
"status": "active",
"severity": "high",
"updated_at": "2026-05-21",
"path": "records/plugin-dev/KB-PLUGIN-013-mindplace-plugin-publication-runbook.md",
"summary": "Runbook completo de publicação. As Fases 2-5 (Forgejo + ZIP + Release + plugins.json) são automatizadas pelo script bin/mindplace-release.sh. Uso: ./bin/mindplace-release.sh ./docker/glpi/plugins/<plugin>. Requer PAT do Forgejo com escopo write:repository."
},
{
"id": "KB-PLUGIN-014",
"title": "getFromDBByCrit() não limpa $fields em falha — reutilizar a mesma instância vaza estado entre iterações",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"commondbtm",
"getfromdbbycrit",
"bug",
"iteration",
"state-leak"
],
"status": "active",
"severity": "critical",
"path": "records/plugin-dev/KB-PLUGIN-014-getfromdbbycrit-stale-fields.md",
"summary": "id: KB-PLUGIN-014"
},
{
"id": "KB-PLUGIN-015",
"title": "querySelector('.row') colide com elementos de outros plugins no Helpdesk portal",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"helpdesk",
"tilesections",
"dom",
"selectors",
"bootstrap",
"cross-plugin-interference"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-015-querySelector-row-collision.md",
"summary": "id: KB-PLUGIN-015"
},
{
"id": "KB-PLUGIN-016",
"title": "\"Bypass de CSRF e Autenticação Nativa (Bearer Token) para APIs Customizadas no GLPI 11\"",
"domain": "plugin-dev",
"tags": [
"glpi-11",
"csrf",
"api",
"jwt",
"oauth",
"auth",
"stateless"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-016-glpi11-csrf-bypass-and-api-auth.md",
"summary": "id: KB-PLUGIN-016"
},
{
"id": "KB-PLUGIN-017",
"title": "Bug do detector de wrapper directory no instalador Mindplace mutilava nomes de arquivos",
"domain": "plugin-dev",
"tags": [
"mindplace",
"marketplace",
"zip",
"extractor",
"install-bug"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-017-mindplace-zip-wrapper-detector-bug.md",
"summary": "id: KB-PLUGIN-017"
},
{
"id": "KB-PLUGIN-018",
"title": "Convenção de wrapper directory para ZIPs de release de plugin GLPI distribuídos via Mindplace",
"domain": "plugin-dev",
"tags": [
"mindplace",
"release",
"zip",
"github-actions",
"workflow"
],
"status": "active",
"severity": "medium",
"path": "records/plugin-dev/KB-PLUGIN-018-plugin-release-zip-wrapper-convention.md",
"summary": "id: KB-PLUGIN-018"
},
{
"id": "KB-PLUGIN-019",
"title": "Arquitetura BFF (Backend For Frontend) e Seguranca via API no GLPI 11 (Plugin MCProtocol)",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"mcp",
"api",
"rbac",
"security",
"bff"
],
"status": "active",
"severity": "high",
"path": "records/plugin-dev/KB-PLUGIN-019-mcprotocol-bff-architecture.md",
"summary": "id: KB-PLUGIN-019"
},
{
"id": "KB-PLUGIN-020",
"title": "Roadmap de Evolucao do MCProtocol (Views Semanticas API)",
"domain": "plugin-dev",
"tags": [
"glpi",
"glpi11",
"mcp",
"api",
"roadmap",
"bff"
],
"status": "active",
"severity": "medium",
"path": "records/plugin-dev/KB-PLUGIN-020-mcprotocol-bff-roadmap.md",
"summary": "id: KB-PLUGIN-020"
}
]
}

View file

@ -0,0 +1,66 @@
---
id: KB-INFRA-001
title: Caminho correto de plugins no container GLPI oficial
domain: infrastructure
tags:
- glpi
- docker
- plugins
- mount
- orbstack
status: active
severity: high
created_at: 2026-05-01
updated_at: 2026-05-01
applies_to:
- glpi/glpi:latest
- macOS + OrbStack
- ambiente local de desenvolvimento
related_records: []
---
# Contexto
O plugin `GLPI Matrix` nao era exibido na interface do GLPI, mesmo existindo no host e no container.
# Sintoma observavel
- Plugin presente em `docker/glpi/plugins/glpimatrix`.
- Interface de plugins do GLPI nao listava o plugin.
# Causa raiz
O bind mount estava apontando para caminho incorreto do container:
- **Incorreto**: `/var/www/html/glpi/plugins`
- **Correto (nesta imagem)**: `/var/www/glpi/plugins`
A imagem `glpi/glpi:latest` usada neste projeto executa o core em `/var/www/glpi`.
# Correcao aplicada
No `docker-compose.yml`, atualizar volumes do servico `glpi`:
- `./docker/glpi/plugins:/var/www/glpi/plugins`
- `./docker/glpi/files:/var/glpi/files`
- `./docker/glpi/config:/var/glpi/config`
- `./docker/glpi/marketplace:/var/glpi/marketplace`
# Validacao padrao
Executar:
1. `docker compose up -d --force-recreate glpi`
2. `docker exec glpi11-app ls -la /var/www/glpi/plugins`
3. `curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8081`
Esperado:
- diretorio do plugin visivel em `/var/www/glpi/plugins`;
- HTTP `200`;
- plugin listavel em `Configuracao > Plugins` apos refresh.
# Impacto
- Sem essa correcao, agentes podem interpretar incorretamente que o plugin esta invalido.
- O problema e de infraestrutura/container path, nao de logica do plugin.
# Regra para agentes
Antes de diagnosticar erro funcional de plugin, validar:
1. caminho de mount do plugin;
2. estrutura interna da imagem em uso;
3. cache e estado do container.
# Classificacao
- Tipo: Licao aprendida de infraestrutura.
- Reutilizacao: obrigatoria em novos setups locais GLPI.

View file

@ -0,0 +1,89 @@
---
id: KB-INFRA-002
title: Icone de plugin GLPI 11 requer logo.png PNG na raiz do diretorio
domain: infrastructure
tags:
- glpi
- glpi11
- plugin
- icon
- logo
- marketplace
status: active
severity: low
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.x lista de plugins e marketplace
- qualquer plugin local instalado manualmente
related_records:
- KB-INFRA-001
- KB-PLUGIN-006
---
# Contexto
Ao instalar um plugin localmente, o GLPI exibe um avatar gerado com as iniciais do nome
do plugin no lugar de um ícone real. Isso ocorre quando nenhum arquivo de logo está presente.
# Sintoma observável
- Lista de plugins exibe um quadrado colorido com 1-2 letras (ex.: "SI" para "Split ITIL").
- Mesmo com `<logo>` definido no XML do plugin, o ícone remoto pode não carregar.
# Como o GLPI resolve o ícone (ordem de prioridade)
1. **Arquivo local `logo.png`** na raiz do diretório do plugin → servido via `LogoController`
2. URL remota definida em `<logo>` no arquivo `{plugin_key}.xml`
3. **Fallback**: avatar gerado com iniciais das palavras do nome do plugin
Código relevante (`Marketplace/View.php`):
```php
if (Document::isImage(sprintf('%s/logo.png', Plugin::getPhpDir($key)))) {
$logo_url = sprintf('%s/Plugin/%s/Logo', $CFG_GLPI['root_doc'], $key);
}
```
# Especificação do arquivo
| Atributo | Valor |
|-------------|------------------------------------------------|
| Nome | `logo.png` (fixo — hardcoded no LogoController)|
| Localização | raiz do diretório do plugin |
| Formato | **PNG** obrigatório |
| SVG | Não funciona — `Document::isImage()` rejeita |
| Tamanho rec. | 128×128 px ou 256×256 px (quadrado) |
# Rota do LogoController
```
GET /Plugin/{plugin_key}/Logo
```
- Requer autenticação admin (`STRATEGY_ADMIN_ACCESS`)
- Serve `BinaryFileResponse` do arquivo `logo.png`
- Se não existir: retorna 1×1 PNG transparente com HTTP 404
# Verificar se o GLPI aceita o PNG
```bash
docker exec glpi-app php -r "
require '/var/www/glpi/vendor/autoload.php';
echo Document::isImage('/var/www/glpi/plugins/myplugin/logo.png') ? 'VALID' : 'INVALID';
"
```
# Gerar PNG placeholder sem dependências externas
Com Python stdlib (sem Pillow ou ImageMagick):
```python
import struct, zlib
# Gerar array de pixels RGBA, encodar com struct.pack e zlib.compress
# Ver: knowledge-base/samples/generate_plugin_logo.py (a criar)
```
# Regra para agentes
Para qualquer plugin, sempre criar `plugins/{key}/logo.png` (PNG, 128×128).
O arquivo tem prioridade sobre o `<logo>` do XML e elimina o fallback de iniciais.
Não usar SVG — o GLPI não reconhece SVG como imagem válida neste contexto.
# Classificação
- Tipo: Configuração de apresentação do plugin.
- Reutilização: aplicável a qualquer plugin GLPI 11 com presença na lista de plugins.

View file

@ -0,0 +1,57 @@
---
id: KB-PLUGIN-001
title: Instalacao de plugin GLPI requer callbacks install/uninstall
domain: plugin-dev
tags:
- glpi
- plugin
- install
- setup.php
- hooks
status: active
severity: high
created_at: 2026-05-01
updated_at: 2026-05-01
applies_to:
- plugins locais GLPI 11.x
- estrutura setup.php
related_records:
- KB-INFRA-001
---
# Contexto
O plugin `GLPI Matrix` aparecia na lista de plugins, mas falhava ao instalar pela interface.
# Sintoma observavel
- Notificacao generica de falha na instalacao.
- Plugin permanecia em status "Nao instalado".
# Causa raiz
Ausencia dos callbacks de ciclo de vida no `setup.php`:
- `plugin_<chave>_install(): bool`
- `plugin_<chave>_uninstall(): bool`
Sem esses callbacks, o GLPI nao consegue concluir o fluxo de instalacao/desinstalacao.
# Correcao aplicada
Adicionar no `setup.php`:
- `plugin_glpimatrix_install(): bool { return true; }`
- `plugin_glpimatrix_uninstall(): bool { return true; }`
# Validacao padrao
Executar:
1. `php /var/www/glpi/bin/console plugin:install glpimatrix -n`
2. `php /var/www/glpi/bin/console plugin:activate glpimatrix -n`
3. `php /var/www/glpi/bin/console plugin:list`
Esperado:
- instalacao concluida;
- ativacao concluida;
- status final "Habilitado".
# Regra para agentes
Ao criar plugin base, sempre incluir no `setup.php`:
1. `plugin_<chave>_check_prerequisites`
2. `plugin_<chave>_check_config`
3. `plugin_<chave>_install`
4. `plugin_<chave>_uninstall`

View file

@ -0,0 +1,67 @@
---
id: KB-PLUGIN-002
title: getCategory() deve ser sobrescrito em QuestionType de plugin GLPI 11
domain: plugin-dev
tags:
- glpi
- glpi11
- form-builder
- question-type
- javascript
- plugin
status: active
severity: critical
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.x form builder nativo
- plugins que estendem QuestionTypeItemDropdown ou qualquer QuestionType nativo
related_records:
- KB-PLUGIN-003
- KB-PLUGIN-004
- KB-PLUGIN-005
---
# Contexto
Ao criar um QuestionType de plugin que estende uma classe nativa (ex.: `QuestionTypeItemDropdown`),
o método `getCategory()` herdado retorna a categoria nativa (ex.: `QuestionTypeCategory::ITEM`).
O plugin registra sua própria categoria via `QuestionTypesManager::registerPluginCategory()`.
# Sintoma observável
- Questão adicionada ao formulário dispara erros JS:
- `Error fetching supported value operators` (400)
- `TypeError: can't access property 'convertDefaultValue', this[#options][type] is undefined`
- Painel de configuração da questão não renderiza nenhum input.
# Causa raiz
`QuestionTypesManager::getCategoryKey()` gera uma chave por categoria.
O JS do editor (`EditorController.js`) usa `#changeQuestionTypeCategory()` para listar os tipos
disponíveis na categoria selecionada pelo usuário. Se `getCategory()` retorna a categoria nativa
em vez da do plugin, o mapa `this.#options` não contém o tipo do plugin para aquela categoria,
e qualquer tentativa de acessar `this.#options[type]` resulta em `undefined`.
# Correção aplicada
Sobrescrever `getCategory()` na classe do plugin para retornar a instância da categoria registrada:
```php
#[Override]
public function getCategory(): QuestionTypeCategoryInterface
{
return new QuestionTypeCategorySplititil();
}
```
# Validação padrão
1. Abrir o form builder do GLPI 11.
2. Criar um formulário e adicionar uma questão do tipo do plugin.
3. Verificar no console do browser: zero erros JS.
4. Verificar que o painel lateral exibe os inputs de configuração corretos.
# Regra para agentes
**Todo QuestionType de plugin DEVE sobrescrever `getCategory()`** para retornar a instância
da sua própria categoria, mesmo que estenda um tipo nativo.
Nunca confiar no `getCategory()` herdado quando a categoria do plugin é diferente da categoria pai.
# Classificação
- Tipo: Armadilha arquitetural crítica do GLPI 11 form builder.
- Reutilização: obrigatória em qualquer plugin que registre QuestionType no GLPI 11.

View file

@ -0,0 +1,65 @@
---
id: KB-PLUGIN-003
title: JSON em <script type="application/json"> nao deve usar htmlspecialchars
domain: plugin-dev
tags:
- glpi
- glpi11
- form-builder
- javascript
- json
- xss
- php
status: active
severity: critical
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.x renderEndUserTemplate
- qualquer contexto PHP que embuta JSON em tag <script>
related_records:
- KB-PLUGIN-002
---
# Contexto
Ao embutir dados PHP como JSON dentro de um elemento `<script type="application/json">`,
é comum aplicar `htmlspecialchars()` por hábito de segurança HTML. Isso é incorreto neste contexto.
# Sintoma observável
- Widget cascading não renderiza — nenhum `<select>` é exibido ao usuário.
- Console do browser exibe: `SyntaxError: JSON.parse: unexpected character`.
- O JSON inspecionado no DOM contém `&quot;` no lugar de `"`, `&lt;` no lugar de `<`.
# Causa raiz
O browser NÃO decodifica entidades HTML dentro de `<script type="application/json">`.
O conteúdo é tratado como texto puro (não como HTML). Aplicar `htmlspecialchars()` sobre o JSON
transforma `"chave": "valor"` em `&quot;chave&quot;: &quot;valor&quot;`, que é JSON inválido.
```php
// ERRADO — corrompe as aspas do JSON
$json = htmlspecialchars(json_encode($data, JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8');
// CORRETO — JSON_HEX_TAG escapa < e > para evitar </script> acidental
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP);
```
# Por que JSON_HEX_TAG e JSON_HEX_AMP
- `JSON_HEX_TAG`: converte `<``<` e `>``>`, impedindo que `</script>` apareça
literalmente e quebre o bloco script. Isso SIM é necessário para segurança.
- `JSON_HEX_AMP`: converte `&``&`, evita problemas em contextos XML/XHTML.
- `htmlspecialchars()` não deve ser usado — ele gera entidades HTML, não sequências JSON.
# Validação padrão
1. Inspecionar o DOM e copiar o conteúdo do `<script type="application/json">`.
2. Colar no console: `JSON.parse('<conteudo>')` — deve retornar o objeto sem erro.
3. Verificar que nenhum `&quot;`, `&lt;`, `&gt;` ou `&amp;` aparece no JSON.
# Regra para agentes
Ao embutir JSON em `<script type="application/json">`:
- Usar `json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP)`.
- **Nunca** passar o resultado de `json_encode()` por `htmlspecialchars()`.
- `htmlspecialchars()` é correto para atributos HTML e conteúdo de texto. Não para JSON em script.
# Classificação
- Tipo: Bug de segurança/encoding com impacto funcional crítico.
- Reutilização: obrigatória em qualquer renderização PHP de dados JSON para JS.

View file

@ -0,0 +1,67 @@
---
id: KB-PLUGIN-004
title: Chave do hook ADD_JAVASCRIPT deve ser o plugin key exato
domain: plugin-dev
tags:
- glpi
- glpi11
- hooks
- javascript
- setup.php
- plugin-key
status: active
severity: high
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.x
- qualquer plugin que injete JS via PLUGIN_HOOKS
related_records:
- KB-PLUGIN-001
- KB-PLUGIN-002
---
# Contexto
Ao registrar arquivos JS em `setup.php` via `$PLUGIN_HOOKS[Hooks::ADD_JAVASCRIPT]`,
a chave do array deve ser exatamente o plugin key — o mesmo nome do diretório do plugin.
# Sintoma observável
- O arquivo JS do plugin nunca é incluído nas páginas do GLPI.
- Nenhum erro é exibido — a falha é silenciosa.
- Funcionalidades que dependem do JS simplesmente não funcionam.
# Causa raiz
`getPluginsJsScriptsFiles()` itera sobre `$PLUGIN_HOOKS['add_javascript']` e chama
`Plugin::isPluginActive($plugin)` usando a **chave** do array como nome do plugin.
Se a chave for diferente do plugin key real, `isPluginActive()` retorna `false`
e o arquivo JS é ignorado sem qualquer aviso.
```php
// ERRADO — chave diferente do plugin key
$PLUGIN_HOOKS[Hooks::ADD_JAVASCRIPT]['splititil_form'] = ['js/splititil-form.js'];
// CORRETO — chave = plugin key exato
$PLUGIN_HOOKS[Hooks::ADD_JAVASCRIPT]['splititil'] = ['js/splititil-form.js'];
```
# Para formulários públicos (anônimos)
Usar `ADD_JAVASCRIPT_ANONYMOUS_PAGE` além de `ADD_JAVASCRIPT` para que o JS
seja carregado em páginas de formulário acessadas sem login:
```php
$PLUGIN_HOOKS[Hooks::ADD_JAVASCRIPT]['splititil'] = ['js/splititil-form.js'];
$PLUGIN_HOOKS[Hooks::ADD_JAVASCRIPT_ANONYMOUS_PAGE]['splititil'] = ['js/splititil-form.js'];
```
# Validação padrão
1. Verificar no HTML renderizado se o `<script src="...splititil-form.js">` está presente.
2. Ou via `grep`: `curl -s http://localhost/glpi/ | grep "splititil-form"`.
# Regra para agentes
Ao registrar hooks de JS/CSS em `setup.php`, a chave do array **sempre** deve ser
o plugin key exato (= nome do diretório do plugin). Nunca usar sufixos, prefixos ou variações.
Para formulários públicos, registrar também em `ADD_JAVASCRIPT_ANONYMOUS_PAGE`.
# Classificação
- Tipo: Armadilha de configuração — falha silenciosa.
- Reutilização: obrigatória em qualquer plugin que injete assets no GLPI 11.

View file

@ -0,0 +1,96 @@
---
id: KB-PLUGIN-005
title: ITILCategoryFieldStrategy LAST_VALID_ANSWER ignora subclasses de QuestionTypeItemDropdown
domain: plugin-dev
tags:
- glpi
- glpi11
- form-builder
- form-destination
- itil-category
- answer-pipeline
- question-type
status: active
severity: critical
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.x form builder — destino de formulário (FormDestinationTicket/Change/Problem)
- plugins com QuestionType que estendem QuestionTypeItemDropdown para ITILCategory
related_records:
- KB-PLUGIN-002
- KB-PLUGIN-006
---
# Contexto
Ao criar um QuestionType de plugin que retorna uma ITILCategory como resposta,
o GLPI não aplica automaticamente essa categoria ao ticket criado pelo formulário.
# Sintoma observável
- O usuário seleciona uma categoria no formulário.
- O ticket é criado sem `itilcategories_id` (campo vazio/zero).
- Nenhum erro é exibido — a categoria simplesmente é ignorada.
# Causa raiz
`ITILCategoryFieldStrategy::LAST_VALID_ANSWER` filtra respostas por tipo assim:
```php
$valid_answers = $answers_set->getAnswersByType(QuestionTypeItemDropdown::class);
```
`AnswersSet::getAnswersByType()` usa **comparação exata** (`==`), não `is_a()`:
```php
fn(Answer $answer) => $answer->getRawType() == $type
```
O `raw_question_type` de um `QuestionTypeSplititil` é armazenado como
`'GlpiPlugin\Splititil\QuestionTypeSplititil'`, que não é igual a
`'Glpi\Form\QuestionType\QuestionTypeItemDropdown'`.
Resultado: a resposta é invisível para a estratégia padrão.
# Solução — AbstractConfigField silencioso
Registrar um campo de destino de plugin que roda **após** `ITILCategoryField` e
sobrescreve `itilcategories_id` quando encontra uma resposta da nossa classe:
```php
// Em setup.php (dentro de plugin_init_*)
FormDestinationManager::getInstance()->registerPluginCommonITILConfigField(
AbstractCommonITILFormDestination::class, // aplica a Ticket, Change e Problem
new \GlpiPlugin\Splititil\SplititilITILCategoryApplicator()
);
```
O `SplititilITILCategoryApplicator` estende `AbstractConfigField` com:
- `renderConfigForm()` retorna `''` → invisível na UI de configuração
- `applyConfiguratedValueToInputUsingAnswers()` varre as respostas buscando pelo
tipo exato da nossa classe e seta `itilcategories_id` no input do ticket
# Por que esse campo roda após ITILCategoryField
Em `createDestinationItems()`, os campos `Entity`, `Template` e `ITILCategoryField`
são aplicados antecipadamente e adicionados a `$already_applied_fields`.
Os campos de plugin são aplicados no `foreach` subsequente e **não** são filtrados
(porque têm uma classe diferente de `ITILCategoryField`), sobrescrevendo o valor anterior.
# Mínimo para implementar AbstractConfigField de plugin
Além dos defaults de `AbstractConfigField`, implementar obrigatoriamente:
`getLabel()`, `renderConfigForm()`, `getConfigClass()`, `getDefaultConfig()`,
`getWeight()`, `getCategory()`
+ uma classe config mínima implementando `JsonFieldInterface`
(`jsonDeserialize()` + `jsonSerialize()`).
# Validação padrão
1. Submeter formulário com questão de categoria preenchida.
2. Abrir o ticket criado e verificar que `itilcategories_id` está preenchido.
3. Confirmar no banco: `SELECT itilcategories_id FROM glpi_tickets ORDER BY id DESC LIMIT 1;`
# Regra para agentes
Qualquer QuestionType de plugin que retorne ITILCategory DEVE implementar um
`AbstractConfigField` silencioso registrado via `registerPluginCommonITILConfigField()`.
Não existe forma nativa de fazer o `LAST_VALID_ANSWER` reconhecer subclasses
sem modificar o core do GLPI.
# Classificação
- Tipo: Limitação arquitetural do GLPI 11 + solução via ponto de extensão oficial.
- Reutilização: obrigatória em qualquer plugin de categoria ITIL no form builder.

View file

@ -0,0 +1,107 @@
---
id: KB-PLUGIN-006
title: Procedimento completo de renomeacao de plugin GLPI
domain: plugin-dev
tags:
- glpi
- glpi11
- plugin
- rename
- refactor
- namespace
- database
status: active
severity: medium
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.x
- qualquer plugin sendo renomeado ou criado como fork
related_records:
- KB-PLUGIN-001
- KB-INFRA-001
---
# Contexto
Renomear um plugin GLPI envolve muito mais do que trocar o nome do diretório.
O plugin key permeia namespace PHP, hooks, tabelas de banco, URLs de ajax, classes JS/CSS
e o registro na tabela `glpi_plugins`. Uma renomeação incompleta causa falhas silenciosas.
# Mapa completo de substituições (do mais específico para o mais geral)
Execute na seguinte ordem para evitar substituições duplas:
```
GlpiPlugin\OldName → GlpiPlugin\NewName (namespace)
PluginOldName → PluginNewName (prefixo de classe legado)
PLUGIN_OLDNAME → PLUGIN_NEWNAME (constantes)
plugin_oldname → plugin_newname (funções e hook keys)
glpi_plugin_oldname → glpi_plugin_newname (tabelas de banco)
QuestionTypeOldName → QuestionTypeNewName (classes src/)
data-oldname- → data-newname- (atributos JS)
oldname-group → newname-group (classes CSS)
oldname_container → newname_container (classes CSS)
@oldname/@newname/ (namespace Twig)
"oldname" → "newname" (string literal entre aspas)
OldName → NewName (PascalCase restante)
oldname → newname (lowercase restante)
"Old Display Name" → "New Display Name" (nome de exibição)
"Old Author" → "New Author" (autor)
```
Comando com perl (mais confiável que sed no macOS):
```bash
find ./plugins/oldname -type f ! -path "*/vendor/*" ! -name "*.png" \
| xargs perl -i -pe 's/GlpiPlugin\\\\OldName/GlpiPlugin\\\\NewName/g; ...'
```
# Arquivos a renomear (após substituir conteúdo)
```bash
mv ajax/oldname.php ajax/newname.php
mv ajax/oldname_data.php ajax/newname_data.php
mv public/css/oldname.css public/css/newname.css
mv public/js/oldname-form.js public/js/newname-form.js
mv public/js/oldname.js.php public/js/newname.js.php
mv oldname.xml newname.xml
mv src/QuestionTypeOldName.php src/QuestionTypeNewName.php
# ... demais arquivos src/
mv plugins/oldname plugins/newname # por último
```
# Pós-renomeação: atualizar o GLPI
```bash
# Remover registro órfão do nome antigo (diretório não existe mais)
mysql -u glpi -p glpi -e "DELETE FROM glpi_plugins WHERE directory='oldname';"
# Instalar e ativar com o novo key
docker exec glpi-app php /var/www/glpi/bin/console plugin:install newname
docker exec glpi-app php /var/www/glpi/bin/console plugin:activate newname
docker exec glpi-app php /var/www/glpi/bin/console plugin:list
```
# Verificação final
```bash
# Zero referências ao nome antigo (exceto menções históricas intencionais)
grep -rn "oldname\|OldName\|OLDNAME" ./plugins/newname \
--exclude-dir=vendor --exclude="*.png" | grep -v "vendor"
# Sintaxe PHP OK
find ./plugins/newname -name "*.php" ! -path "*/vendor/*" \
| xargs php -l | grep -v "No syntax errors"
```
# Regra para agentes
Ao renomear um plugin:
1. Substituir conteúdo (ordem do específico para o geral)
2. Renomear arquivos individuais
3. Renomear o diretório
4. Limpar registro órfão no banco
5. Reinstalar via console
Nunca renomear o diretório antes de substituir o conteúdo — o container pode perder a referência.
# Classificação
- Tipo: Procedimento operacional de refatoração.
- Reutilização: aplicável a qualquer renomeação ou fork de plugin GLPI.

View file

@ -0,0 +1,54 @@
---
id: KB-PLUGIN-007
title: GLPI 11 marca QuestionTypeItemDropdown como final
domain: plugin-dev
tags:
- glpi
- glpi11
- form-builder
- question-type
- fatal-error
status: active
severity: critical
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.0.x
---
# GLPI 11 marca QuestionTypeItemDropdown como final
## Sintoma
Ao instalar ou utilizar um plugin que estenda `QuestionTypeItemDropdown` em ambiente de produção do GLPI 11, ocorre o seguinte erro fatal que pode derrubar o sistema (Erro 500):
`Fatal Compile Error: Class GlpiPlugin\NomeDoPlugin\SuaClasse cannot extend final class Glpi\Form\QuestionType\QuestionTypeItemDropdown`
## Causa
Em versões recentes e estáveis do GLPI 11, a classe `Glpi\Form\QuestionType\QuestionTypeItemDropdown` (assim como outras nativas) recebeu a declaração `final`. O PHP impede que classes marcadas como finais sejam estendidas, gerando um erro de compilação assim que o arquivo é carregado pelo autoloader.
Isso geralmente ocorre em plugins que tentam reaproveitar a lógica do dropdown de itens padrão do GLPI apenas sobrescrevendo a renderização.
## Resolucao / Workaround
Não tente estender a classe final. Em vez disso:
1. **Estenda a classe base**
Mude a herança da sua classe para estender a classe não-final imediatamente superior na hierarquia. Para o dropdown, use a classe base `QuestionTypeItem`.
```php
// ANTES (Causa erro)
class MinhaQuestao extends QuestionTypeItemDropdown
// DEPOIS (Funciona corretamente)
use Glpi\Form\QuestionType\QuestionTypeItem;
class MinhaQuestao extends QuestionTypeItem
```
2. **Composição Manual (Duck Typing)**
Re-implemente os métodos que você precisava herdar do `QuestionTypeItemDropdown`. Muitas vezes é necessário copiar configurações do `getExtraDataConfigClass`, repassar parâmetros de filtragem ou copiar a estrutura do template Twig nativo.
## Exemplo de Impacto
No plugin de categorização em cascata, a herança direta impedia a ativação em produção. Mudar para `QuestionTypeItem` exigiu também reconstruir a lógica que carrega e filtra as categorias respeitando configuração de interface (`is_helpdeskvisible`), tipos habilitados e profundidade da árvore.

View file

@ -0,0 +1,69 @@
---
id: KB-PLUGIN-008
title: Interfaces de Validacao Customizada no Form Builder do GLPI 11
domain: plugin-dev
tags:
- glpi
- glpi11
- form-builder
- validation
- javascript
status: active
severity: medium
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.0.x
---
# Interfaces de Validacao Customizada no Form Builder do GLPI 11
## Contexto
Ao criar um novo tipo de questão (`QuestionType`) para o Form Builder do GLPI 11, o motor nativo utiliza validações genéricas. Por exemplo, se a questão for marcada como obrigatória e estiver vazia, o erro genérico exibido será "This field is mandatory".
## Como personalizar mensagens de erro
O GLPI fornece dois pontos de extensão na forma de interfaces que sua classe `QuestionType` pode implementar:
### 1. `CustomMandatoryMessageInterface`
Usada para interceptar a falha da checagem de "campo obrigatório".
Quando a resposta (após as devidas transformações) for considerada vazia, o GLPI chamará o método fornecido para exibir a mensagem.
```php
use Glpi\Form\QuestionType\CustomMandatoryMessageInterface;
class MinhaQuestao extends AbstractQuestionType implements CustomMandatoryMessageInterface
{
public function getCustomMandatoryErrorMessage(): string
{
return __('Por favor, preencha todos os campos do widget.', 'meuplugin');
}
}
```
### 2. `QuestionTypeValidationInterface`
Usada para validações complexas ou de domínio quando o campo **possui um valor preenchido**, mas pode ser inválido para as regras de negócio.
```php
use Glpi\Form\QuestionType\QuestionTypeValidationInterface;
use Glpi\Form\ValidationResult;
class MinhaQuestao extends AbstractQuestionType implements QuestionTypeValidationInterface
{
public function validateAnswer(Question $question, mixed $answer): ValidationResult
{
$result = new ValidationResult();
// Exemplo: campo não pode ter um valor que termine em 0
if (is_numeric($answer) && $answer % 10 === 0) {
$result->addError($question, __('O valor não pode terminar em zero.', 'meuplugin'));
}
return $result;
}
}
```
## UI de Validação do Front-end
Note que os erros retornados (via AJAX para `/Form/ValidateAnswers`) acionam o script `RendererController.js` do GLPI, que buscará no DOM os elementos correspondentes e aplicará as classes `is-invalid` nativas juntamente com o balão `.invalid-tooltip`. Não é recomendável forçar validações HTML5 (`setCustomValidity`) via JavaScript se estiver interagindo diretamente com o motor do Form Builder.

View file

@ -0,0 +1,65 @@
---
id: KB-PLUGIN-009
title: Validacao mandatory ignora respostas que sao arrays nao-vazios com chaves em branco
domain: plugin-dev
tags:
- glpi
- glpi11
- form-builder
- validation
- answers-handler
status: active
severity: high
created_at: 2026-05-02
updated_at: 2026-05-02
applies_to:
- GLPI 11.0.x
---
# Validacao mandatory ignora respostas que sao arrays nao-vazios com chaves em branco
## Sintoma
Um campo obrigatório (mandatory) de um novo `QuestionType` não dispara nenhum erro de validação se o usuário o enviar vazio. Em vez disso, a página tenta enviar e falha, devolvendo um erro genérico do sistema (geralmente gerado na hora de gravar os destinos).
## Causa
O mecanismo do `AnswersHandler` do GLPI verifica campos vazios da seguinte forma:
```php
if ($answer === null || (is_string($answer) && strip_tags($answer) === '') || (is_array($answer) && count($answer) === 0))
```
Em widgets complexos que retornam uma estrutura via vários inputs (como `[items_id]` e `[itemtype]`), se o usuário não selecionar nada, a resposta "crua" (*raw*) pode chegar no backend como um array:
```php
$answer = [
'itemtype' => 'ITILCategory',
'items_id' => ''
];
```
Este array possui 2 chaves, logo `count($answer) === 0` é `false`. O GLPI entende que a pergunta "foi respondida" e pula a checagem de obrigatoriedade.
## Solucao
A classe `QuestionType` deve implementar (ou sobrescrever) o método `transformConditionValueForComparisons` originado da `ConditionValueTransformerInterface`.
O objetivo é examinar a resposta *raw* precocemente e convertê-la ativamente em uma string vazia `''` caso os dados essenciais estejam ausentes.
```php
use Override;
#[Override]
public function transformConditionValueForComparisons(mixed $value, ?\Glpi\DBAL\JsonFieldInterface $question_config): string
{
if (is_array($value)) {
$items_id = $value['items_id'] ?? '';
// Trata chaves em branco ou nulas como resposta vazia global
if ($items_id === '' || $items_id === null || (int)$items_id <= 0) {
return ''; // string vazia dispara o check nativo do AnswersHandler!
}
}
// Passa adiante se tudo estiver ok
return parent::transformConditionValueForComparisons($value, $question_config);
}
```
Ao retornar `''`, o `AnswersHandler` passará a reconhecer que o usuário pulou o campo, ativando corretamente a mensagem de erro (ou a sua `CustomMandatoryMessageInterface` caso definida).

View file

@ -0,0 +1,95 @@
---
id: KB-PLUGIN-010
title: Padrao correto para chamadas AJAX autenticadas em plugins GLPI 11
domain: plugin-dev
tags:
- glpi
- glpi11
- ajax
- csrf
- security
- symfony
- fetch
- plugin
status: active
severity: critical
created_at: 2026-05-03
updated_at: 2026-05-03
applies_to:
- GLPI 11.0.x
---
# Padrao correto para chamadas AJAX autenticadas em plugins GLPI 11
## Sintoma
Chamadas `fetch` POST feitas por um plugin retornam HTTP 403. O plugin está corretamente instalado e o usuário está autenticado com o direito necessário. O erro ocorre mesmo que `Session::checkCSRF()` não seja chamado no PHP do plugin.
## Causa
O GLPI 11 utiliza o kernel Symfony com um listener dedicado (`CheckCsrfListener`) que intercepta **todos os requests POST antes de o PHP do plugin executar**. Para requests AJAX (identificados pelo header `X-Requested-With: XMLHttpRequest`), ele exige o CSRF token no header **`X-Glpi-Csrf-Token`** — não no body da requisição.
Fonte: `/var/www/glpi/src/Glpi/Kernel/Listener/ControllerListener/CheckCsrfListener.php`
```php
if ($request->isXmlHttpRequest()) {
Session::checkCSRF(
['_glpi_csrf_token' => $request->server->get('HTTP_X_GLPI_CSRF_TOKEN') ?? ''],
preserve_token: true // token não é consumido — reutilizável em múltiplas chamadas
);
} else {
Session::checkCSRF($request->request->all());
}
```
## Onde fica o token
O token é renderizado pelo layout do GLPI em uma meta tag:
```html
<meta property="glpi:csrf_token" content="TOKEN_AQUI" />
```
Com `preserve_token: true`, o mesmo token permanece válido para todas as chamadas AJAX da sessão — não é consumido.
## Solucao
### JavaScript (plugin)
```js
function csrfToken() {
var meta = document.querySelector('meta[property="glpi:csrf_token"]');
return meta ? meta.getAttribute('content') : '';
}
fetch('/plugins/meu-plugin/ajax/endpoint.php', {
method: 'POST',
credentials: 'same-origin', // envia o cookie de sessão
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest', // identifica como AJAX para o kernel
'X-Glpi-Csrf-Token': csrfToken() // token exigido pelo CheckCsrfListener
},
body: new URLSearchParams({ action: 'minha_action', key: 'valor' })
});
```
### PHP (endpoint do plugin)
**Não chamar** `Session::checkCSRF()` — o kernel já fez a verificação. Apenas verificar o direito do usuário:
```php
<?php
include('../../../inc/includes.php');
Session::checkRight('config', UPDATE); // suficiente — kernel já validou CSRF
// lógica do endpoint...
```
## Comportamento esperado
Com o padrão acima:
- Kernel valida CSRF via header antes do PHP rodar
- PHP recebe a requisição autenticada normalmente
- Token não é consumido (`preserve_token: true`), então múltiplos POSTs consecutivos funcionam sem gerar novo token

View file

@ -0,0 +1,77 @@
---
id: KB-PLUGIN-011
title: "Procedimento de Release do Plugin Mindplace (LEGADO — GitHub)"
domain: plugin-dev
tags:
- github
- release
- secrets
- deprecated
status: deprecated
severity: medium
created_at: 2024-05-10
updated_at: 2026-05-21
applies_to:
- Plugin Mindplace (versão pré-Forgejo)
supersedes: KB-PLUGIN-013
---
> **⚠️ DEPRECATED** — O procedimento aqui descrito refere-se à fase em que o Mindplace usava o GitHub como backend de releases. Desde 2026-05, o backend é o **Forgejo da Mindtek** (`servicedesk.mindtek.com.br/git`) e o fluxo de release é automatizado pelo script `bin/mindplace-release.sh`.
>
> Para o procedimento atual, ver **[KB-PLUGIN-013](KB-PLUGIN-013-mindplace-plugin-publication-runbook.md)**.
# KB-PLUGIN-011 — Procedimento de Release do Plugin Mindplace (legado)
## Contexto
O plugin Mindplace usa um GitHub PAT (`secrets.php`) para autenticar chamadas à API do GitHub e aumentar o rate limit de 60 para 5.000 req/hora. Esse arquivo é **gitignored** — nunca entra no repositório público — mas precisa estar presente no ZIP distribuído aos clientes.
O ZIP gerado automaticamente pelo GitHub (Source code) **não inclui** arquivos gitignored. Por isso o ZIP de release deve ser criado manualmente.
## Arquivos sensíveis
| Arquivo | Git | ZIP cliente |
|---|---|---|
| `config/secrets.php` | gitignored ✗ | incluído ✓ |
| `config/secrets.php.example` | commitado ✓ | excluído ✗ |
## Procedimento para gerar o ZIP de release
```bash
cd docker/glpi/plugins/mindplace
zip -r mindplace-1.0.0.zip . \
--exclude "*.git*" \
--exclude "config/secrets.php.example"
```
Substitua `1.0.0` pela versão da release.
## Upload no GitHub
1. Acesse o repositório `rodolphoolopes/mindplace` no GitHub
2. Releases → Draft a new release
3. Crie a tag da versão (ex: `v1.0.0`)
4. Em **Assets**, faça upload do `mindplace-1.0.0.zip` gerado acima
5. **Não usar** o ZIP automático "Source code" gerado pelo GitHub
## Estrutura do secrets.php
```php
<?php
define('MINDPLACE_GITHUB_PAT', 'github_pat_...');
```
O PAT deve ter permissões:
- Repositório: `rodolphoolopes/mindplace`
- Contents: Read-only
- Metadata: Read-only (obrigatório pelo GitHub)
## Como o código lê o PAT
`PluginConfig::getGithubPat()` faz `include_once` do `secrets.php` se ele existir. Se o arquivo não existir, retorna string vazia e o plugin funciona sem autenticação (rate limit de 60 req/hora).
## Atenção
- Nunca commitar `secrets.php` — o GitHub faz varredura automática em repos públicos e **revoga o token imediatamente** se detectar
- Ao renovar o PAT, atualizar o `secrets.php` local e regerar o ZIP de todas as versões ativas

View file

@ -0,0 +1,276 @@
---
id: KB-PLUGIN-012
title: Estendendo o sistema de Tiles do Helpdesk no GLPI 11 — padrões e armadilhas
domain: plugin-dev
tags:
- glpi
- glpi11
- helpdesk
- tiles
- dom-manipulation
- mutation-observer
- twig
- singleton
- plugin
status: active
severity: high
created_at: 2026-05-09
updated_at: 2026-05-09
applies_to:
- GLPI 11.0.x
---
# Estendendo o sistema de Tiles do Helpdesk no GLPI 11
Documento consolidado das armadilhas encontradas ao implementar o plugin `tilesections` (categorização visual de tiles na "Página Inicial do Helpdesk"). O sistema de tiles do GLPI 11 é nativamente plano (sem agrupamento) e não foi desenhado para extensão por plugins — qualquer modificação visual precisa contornar isso.
## 1. Estrutura DOM dos tiles no admin (Entidade > Página Inicial do Helpdesk)
Cada tile na grade de configuração é renderizado pela template `templates/pages/admin/helpdesk_home_config_tiles.html.twig` em **dois níveis aninhados**:
```html
<section
class="col-12 col-lg-6 col-xl-4 d-flex-soft pointer-events-none"
data-glpi-draggable-item
data-glpi-helpdesk-config-tile-container ← wrapper externo
data-glpi-helpdesk-config-action-show-edit-form
data-bs-toggle="offcanvas"
>
<div
data-glpi-helpdesk-config-tile
data-glpi-helpdesk-config-tile-id="123" ← ID está aqui
data-glpi-helpdesk-config-tile-itemtype="..."
class="card rounded my-2 cursor-pointer"
>
...
</div>
</section>
```
### Armadilha
Mover apenas o `<div>` interno (que tem o `data-glpi-helpdesk-config-tile-id`) **destrói o layout**:
- Perde as classes de coluna (`col-12 col-lg-6 col-xl-4`) → tile vira full-width
- Perde os atributos de drag (`data-glpi-draggable-item`) → não é mais reordenável
- Perde o trigger do offcanvas (`data-bs-toggle="offcanvas"`) → click pra editar não funciona
### Padrão correto
Selecionar o **outer container** com `[data-glpi-helpdesk-config-tile-container]` e ler os IDs do `<div>` interno:
```js
const tileOuters = container.querySelectorAll('[data-glpi-helpdesk-config-tile-container]');
tileOuters.forEach(outer => {
const inner = outer.querySelector('[data-glpi-helpdesk-config-tile-id]');
const itemtype = inner.getAttribute('data-glpi-helpdesk-config-tile-itemtype');
const tileId = inner.getAttribute('data-glpi-helpdesk-config-tile-id');
// mover `outer` (não `inner`)
});
```
## 2. TilesManager é singleton — `__construct` é privado
```php
// ❌ ERRADO — Call to private __construct from scope ...
$manager = new \Glpi\Helpdesk\Tile\TilesManager();
// ✅ CORRETO
$manager = \Glpi\Helpdesk\Tile\TilesManager::getInstance();
```
## 3. Como o GLPI obtém os tiles que serão renderizados no portal público
O `Glpi\Controller\Helpdesk\IndexController` (que renderiza o portal `/Helpdesk`) chama:
```php
$manager->getVisibleTilesForSession(Session::getCurrentSessionInfo())
```
Esse método:
1. Pega os tiles do **profile atual**; se vazio, sobe pelo entity tree
2. Filtra por `$tile->isAvailable($session_info)` (permissões, configurações, etc.)
3. Retorna `array<TileInterface&CommonDBTM>` na ordem que serão renderizados
**Use o mesmo método em endpoints AJAX do seu plugin** para garantir alinhamento perfeito com o que o usuário vê — inclusive quando o admin está personificando outro perfil.
## 4. Tiles do portal público NÃO têm `data-attribute` de identificação
O template `templates/pages/helpdesk/index.html.twig` renderiza apenas:
```html
<a class="card mx-1 my-2 flex-grow-1" href="{{ tile.getTileUrl() }}">
```
Não há `data-tile-id`, `data-itemtype`, etc. **Não dá pra fazer matching reverso por ID.**
### Armadilha: matching por URL é frágil
Usar `tile.getTileUrl()` como chave de matching falha em casos comuns:
- **URL vazia**: `ExternalPageTile` retorna `""` se o campo `url` estiver vazio. Múltiplos tiles com URL vazia colidem na mesma chave (`href::`) e o último processado "ganha", varrendo os outros.
- **URLs duplicadas**: dois `ExternalPageTile` apontando pra mesma URL têm chave idêntica.
- **Encoding**: query strings com `[]` (`criteria[0][field]=...`) podem ser escapadas diferentemente entre o servidor e o atributo HTML.
### Padrão correto: matching por índice de renderização
```php
// servidor — endpoint AJAX
$rendered = TilesManager::getInstance()
->getVisibleTilesForSession(Session::getCurrentSessionInfo());
$tile_order = [];
foreach ($rendered as $tile) {
$tile_order[] = [
'itemtype' => get_class($tile),
'items_id' => (int) $tile->getDatabaseId(),
'sections_id' => (int) ($mappings[get_class($tile) . '::' . $tile->getDatabaseId()] ?? 0),
];
}
```
```js
// cliente — JS do portal
const tileWrappers = tilesRow.querySelectorAll(':scope > div');
tileWrappers.forEach((wrapper, idx) => {
const tileInfo = tileOrder[idx]; // 1:1 garantido pela ordem
if (!tileInfo) return;
const sectionId = tileInfo.sections_id;
// ...
});
```
Funciona porque a sessão da chamada AJAX é a mesma sessão que renderizou a página — o `getVisibleTilesForSession` retorna a lista exata na ordem exata.
## 5. GLPI re-renderiza tiles via AJAX após delete/reorder
Quando o usuário deleta ou reordena um tile na tela de admin, o GLPI faz um AJAX e **substitui o conteúdo do container** `[data-glpi-helpdesk-config-tiles]` com a lista atualizada do servidor. Isso varre qualquer reorganização visual aplicada pelo plugin.
### Padrão correto: MutationObserver com guarda de re-entrada
```js
let isReorganizing = false;
function watchGridRefreshes(tilesContainer) {
new MutationObserver(() => {
if (isReorganizing) return;
const hasTiles = tilesContainer.querySelectorAll('[data-glpi-helpdesk-config-tile-container]').length > 0;
const hasSections = tilesContainer.querySelector('.minha-section-wrapper');
if (!hasTiles || hasSections) return;
// Container voltou ao estado plano — re-aplicar reorganização
queueMicrotask(async () => {
await loadServerData();
reorganize(tilesContainer); // setar isReorganizing dentro
});
}).observe(tilesContainer, { childList: true });
}
function reorganize(tilesContainer) {
isReorganizing = true;
// ... mutations no DOM ...
queueMicrotask(() => { isReorganizing = false; });
}
```
A guarda `isReorganizing` é essencial — sem ela, as próprias mutações do plugin disparam o observer em loop infinito.
## 6. Race condition no save de tile — não use polling
Quando o usuário salva um novo tile no offcanvas, o GLPI cria via AJAX e adiciona ao DOM **assíncronamente**. Polling com `setInterval` é frágil:
- "Último tile no DOM" não garante ser o recém-criado (ordenação muda)
- Timeout duro (ex: 6s) pode estourar em conexões lentas
### Padrão correto: snapshot + MutationObserver com diff
```js
function handleAddSave(tilesContainer, sectionId) {
// Snapshot: tile keys antes do save
const before = new Set();
tilesContainer.querySelectorAll('[data-glpi-helpdesk-config-tile-id]').forEach(el => {
const itemtype = el.getAttribute('data-glpi-helpdesk-config-tile-itemtype');
const id = el.getAttribute('data-glpi-helpdesk-config-tile-id');
before.add(`${itemtype}::${id}`);
});
let resolved = false;
const observer = new MutationObserver(() => {
if (resolved) return;
const tiles = tilesContainer.querySelectorAll('[data-glpi-helpdesk-config-tile-id]');
for (const el of tiles) {
const itemtype = el.getAttribute('data-glpi-helpdesk-config-tile-itemtype');
const id = el.getAttribute('data-glpi-helpdesk-config-tile-id');
const key = `${itemtype}::${id}`;
if (!before.has(key)) {
resolved = true;
observer.disconnect();
saveSectionMapping(itemtype, id, sectionId);
return;
}
}
});
observer.observe(tilesContainer, { childList: true, subtree: true });
setTimeout(() => { if (!resolved) observer.disconnect(); }, 15000);
}
```
## 7. Anti-flash: esconder container antes da reorganização
Se a reorganização visual roda dentro de um `setTimeout` (debounce), o usuário vê os tiles em estado "plano" antes de aparecerem categorizados. Solução: adicionar a classe de loading **imediatamente** ao encontrar o container, não dentro do callback do timeout:
```js
function tryInit() {
const tilesContainer = document.querySelector('[data-glpi-helpdesk-config-tiles]');
if (!tilesContainer) return;
tilesContainer.classList.add('ts-loading'); // ← imediato
setTimeout(() => {
if (initialized) return;
initialized = true;
init(tilesContainer); // remove 'ts-loading' no final
}, 300);
}
```
Com CSS:
```css
[data-glpi-helpdesk-config-tiles].ts-loading {
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
```
## 8. Templates de plugin têm namespace, não override direto
GLPI 11 carrega templates de plugin em namespace dedicado:
```php
// Glpi\Application\View\TemplateRenderer
$loader->addPath(Plugin::getPhpDir($plugin_key . '/templates'), $plugin_key);
```
Isso registra `@plugin_key/path/to/template.html.twig`. **Não substitui** o template core no mesmo path.
Para "estender" templates core, opções são:
1. **Hooks**: usar `Hooks::ADD_JAVASCRIPT` / `Hooks::ADD_CSS` para injetar comportamento via JS/CSS
2. **API server-side**: chamar APIs nativas do GLPI (`TilesManager::getInstance()`) em endpoints AJAX próprios
3. **DOM manipulation**: aceitar a fragilidade e robustecer com observers (este KB)
Override direto de template core via plugin **não é suportado oficialmente**.
## Referências do core GLPI 11
| Caminho | Conteúdo |
|---|---|
| `src/Glpi/Helpdesk/Tile/TilesManager.php` | Singleton com `getInstance()` e `getVisibleTilesForSession()` |
| `src/Glpi/Helpdesk/Tile/{FormTile,GlpiPageTile,ExternalPageTile}.php` | Implementações de tile |
| `src/Glpi/Controller/Helpdesk/IndexController.php` | Renderização do portal público |
| `templates/pages/admin/helpdesk_home_config_tiles.html.twig` | Template do admin grid |
| `templates/pages/helpdesk/index.html.twig` | Template do portal público |
| `src/Glpi/Application/View/TemplateRenderer.php` | Mecanismo de namespace de templates de plugin |

View file

@ -0,0 +1,451 @@
---
id: KB-PLUGIN-013
title: Runbook — Publicação de plugin no Mindplace (procedimento padrão)
domain: plugin-dev
tags:
- mindplace
- runbook
- publish
- license
- forgejo
- release
- kill-switch
- workflow
- automation
- script
status: active
severity: high
created_at: 2026-05-09
updated_at: 2026-05-21
applies_to:
- GLPI 11.0.x
- Mindplace 1.0+
- Forgejo (servicedesk.mindtek.com.br/git)
---
# Runbook — Publicação de plugin no Mindplace
Procedimento padrão completo para publicar um novo plugin proprietário no Mindplace (marketplace privado da Mindtek). Cobre desde a integração do kill switch de licença no código até a entrega do serial ao cliente.
> **⚡ TL;DR — Publicação automatizada:** As Fases 2, 3 e 4 (Forgejo + ZIP + Release + plugins.json) estão **100% automatizadas** pelo script `bin/mindplace-release.sh`. Leia a seção **Publicação Automatizada** antes de executar os passos manuais.
## Pré-requisitos
- [ ] Plugin está em estado funcional e testado localmente
- [ ] Acesso de admin ao Forgejo (`servicedesk.mindtek.com.br/git`)
- [ ] Acesso ao GLPI de produção da Mindtek (`servicedesk.mindtek.com.br`) com perfil que crie licenças de software
- [ ] Plugin já tem `setup.php` válido com `plugin_version_<name>()` retornando metadata
- [ ] `version` no `setup.php` segue SemVer (ex: `1.0.0`)
- [ ] **Para publicação automatizada:** `FORGEJO_TOKEN` disponível (PAT com escopo `write:repository`)
---
## ⚡ Publicação Automatizada (Fases 25)
As fases de publicação no Forgejo e catálogo são cobertas pelo script `bin/mindplace-release.sh` localizado na raiz do projeto GLPI11.
### Pré-requisito único: Personal Access Token (PAT)
Gere em: `https://servicedesk.mindtek.com.br/git/user/settings/applications`
- Nome sugerido: `antigravity-publisher`
- Escopo necessário: `write:repository`
Exporte como variável de ambiente (ou edite a variável `FORGEJO_TOKEN` diretamente no script):
```bash
export FORGEJO_TOKEN="seu_token_aqui"
```
### Uso
```bash
# Na raiz do projeto GLPI11:
./bin/mindplace-release.sh ./docker/glpi/plugins/<nome-do-plugin>
```
### O que o script faz automaticamente
| Fase | Ação |
|---|---|
| **1** | Cria repositório no Forgejo (se não existir) |
| **2** | `git init` + commit + push para o Forgejo |
| **3** | Gera ZIP limpo do plugin (exclui `.git`, `vendor`, `node_modules`, `*.DS_Store`) |
| **4** | Cria tag `v{versão}` + Release no Forgejo + faz upload do ZIP como asset |
| **5** | Atualiza `plugins.json` no repo `mindplace` com a entrada do novo plugin |
### Comportamento idempotente
O script é seguro para rodar múltiplas vezes:
- ✅ Repositório já existe → pula criação
- ✅ Release já existe → pula criação
- ✅ Plugin já no `plugins.json` → pula atualização
- ✅ Sempre faz push do código mais recente
### Fluxo de nova versão
```bash
# 1. Bump da versão no setup.php
# 'version' => '1.0.0' → '1.1.0'
# 2. Rodar o script — ele detecta a nova versão e cria nova Release
./bin/mindplace-release.sh ./docker/glpi/plugins/meu-plugin
```
### Notas importantes
- O script lê a versão diretamente da constante `PLUGIN_XXXVERSION` no `setup.php`
- Arquivos de teste (`ajax/test*.php`, `front/test*.php`) devem estar no `.gitignore` do plugin para não serem publicados
- O `.gitignore` padrão para plugins está documentado abaixo
- O ZIP do asset pode acumular múltiplas versões na mesma Release se o script for rodado mais de uma vez sem bump de versão (não causa erro, apenas duplicata)
### .gitignore padrão para plugins
```gitignore
# Dependências
vendor/
node_modules/
# macOS
.DS_Store
**/.DS_Store
# IDE
.idea/
.vscode/
# Logs
*.log
# Build artifacts
*.zip
# Arquivos de teste (não publicar)
ajax/test*.php
ajax/mcp_test.php
front/test*.php
```
---
---
## Fase 1 — Kill switch de licença no código
Cada plugin do Mindplace **precisa** validar a licença antes de fazer qualquer coisa. O padrão do "Kill Switch" funciona em duas camadas:
1. `plugin_<name>_check_config()` impede ativação se licença inválida
2. `plugin_init_<name>()` aborta silenciosamente se licença inválida (proteção em profundidade)
### Template do `setup.php`
```php
<?php
use Glpi\Plugin\Hooks;
function plugin_version_<name>(): array
{
return [
'name' => 'Plugin Name',
'version' => '1.0.0',
'author' => 'Mindtek Tecnologia',
'license' => 'GPLv2+',
'requirements' => ['glpi' => ['min' => '11.0', 'max' => '12.0']],
];
}
function plugin_<name>_check_prerequisites(): bool
{
if (version_compare(GLPI_VERSION, '11.0', '<')) {
echo 'This plugin requires GLPI >= 11.0';
return false;
}
return true;
}
/**
* Carrega a classe License do Mindplace caso o autoload ainda não tenha rodado.
* Necessário porque o plugin pode inicializar antes do mindplace.
*/
function plugin_<name>_load_license(): void
{
if (class_exists('\GlpiPlugin\Mindplace\License')) {
return;
}
foreach ([
GLPI_ROOT . '/plugins/mindplace/src/License.php',
GLPI_ROOT . '/marketplace/mindplace/src/License.php',
] as $path) {
if (file_exists($path)) {
include_once $path;
return;
}
}
}
function plugin_<name>_check_config($verbose = false): bool
{
plugin_<name>_load_license();
if (!class_exists('\GlpiPlugin\Mindplace\License') || !\GlpiPlugin\Mindplace\License::isValid()) {
if ($verbose && !isCommandLine()) {
echo "<div class='alert alert-danger'>Licença Mindtek inativa ou Mind Place ausente. O plugin foi desativado por segurança.</div>";
}
return false;
}
return true;
}
function plugin_init_<name>(): void
{
global $PLUGIN_HOOKS;
$PLUGIN_HOOKS['csrf_compliant']['<name>'] = true;
// ──────────────────────────────────────────────────────────────────
// KILL SWITCH — abort silently if license invalid
// ──────────────────────────────────────────────────────────────────
plugin_<name>_load_license();
if (!class_exists('\GlpiPlugin\Mindplace\License') || !\GlpiPlugin\Mindplace\License::isValid()) {
return;
}
// ─── A partir daqui, registrar todos os hooks/classes do plugin ───
// $PLUGIN_HOOKS[Hooks::ADD_CSS]['<name>'] = [...];
// Plugin::registerClass(...);
}
```
### Comportamento do Kill Switch
| Cenário | Resultado |
|---|---|
| Mindplace não instalado | `check_config` retorna `false` → plugin não pode ativar |
| Mindplace instalado mas licença inválida/expirada | mesmo do anterior |
| Plugin já estava ativo e licença expirou | `init` aborta sem registrar hooks → GLPI ignora o plugin como se não existisse |
| Tudo OK | Plugin funciona normalmente |
### Checklist da Fase 1
- [ ] `plugin_<name>_load_license()` adicionada
- [ ] `plugin_<name>_check_config()` valida licença
- [ ] `plugin_init_<name>()` aborta se inválida
- [ ] Testado: desativar mindplace → tentar ativar plugin → deve falhar com aviso
- [ ] Testado: licença válida → plugin ativa e funciona
---
## Fase 2 — Publicação no Forgejo
Forgejo é onde o código-fonte e os releases ficam. URL base: `https://servicedesk.mindtek.com.br/git/`.
### 2.1 — Criar repositório
1. Acesse o Forgejo logado como admin (`rodolpho.lopes`)
2. **+** → **New Repository**
- Owner: `rodolpho.lopes`
- Repository name: nome do plugin (ex: `tilesections`)
- Visibility: **Public**
- Initialize repository: ✅ (com README mínimo — releases exigem ao menos um commit)
3. **Create Repository**
### 2.2 — Push do código-fonte
```bash
cd /caminho/local/do/plugin
# Se ainda não é um repo git
git init
git branch -M main
# Adicionar Forgejo como remote
git remote add forgejo https://servicedesk.mindtek.com.br/git/rodolpho.lopes/<plugin>.git
# Commit + push
git add .
git commit -m "Initial commit"
git push forgejo main
```
Vai pedir credenciais do Forgejo na primeira vez.
### 2.3 — Criar release com ZIP
1. No Forgejo, no repo do plugin: **Releases** → **New Release**
2. **Tag name**: `v1.0.0` (deve bater com `version` em `setup.php`)
3. **Title**: `v1.0.0`
4. **Description**: changelog resumido
5. Em **Attachments**, fazer upload do ZIP gerado:
```bash
cd /caminho/local/do/plugin
zip -r <plugin>-1.0.0.zip . \
--exclude "*.git*" \
--exclude "node_modules/*" \
--exclude "vendor/*" \
--exclude "*.DS_Store"
```
6. **Publish Release**
### Checklist da Fase 2
- [ ] Repositório criado público no Forgejo
- [ ] Código-fonte push para `main`
- [ ] Logo `logo.png` na raiz do plugin (será exibido no Mindplace)
- [ ] Tag `v<versão>` criada
- [ ] ZIP do plugin anexado como asset da release
- [ ] Versão do ZIP bate com `setup.php`
---
## Fase 3 — Cadastro no catálogo Mindplace
O Mindplace lê `plugins.json` do repositório `rodolpho.lopes/mindplace` no Forgejo. Cada plugin é uma entrada nesse array.
### 3.1 — Editar `plugins.json`
Acesse: `https://servicedesk.mindtek.com.br/git/rodolpho.lopes/mindplace`
Edite o arquivo `plugins.json` (ícone do lápis na visualização do arquivo) e adicione a nova entrada:
```json
{
"key": "<plugin>",
"repo": "rodolpho.lopes/<plugin>",
"name": "Nome Bonito do Plugin",
"description": "Descrição curta de uma frase.",
"authors": [{"name": "Rodolpho O Lopes"}],
"license": "GPL-2.0-or-later",
"logo_url": null,
"homepage_url": "https://servicedesk.mindtek.com.br/git/rodolpho.lopes/<plugin>",
"issues_url": "https://servicedesk.mindtek.com.br/git/rodolpho.lopes/<plugin>/issues",
"note": 5
}
```
**Campos importantes:**
- `key`: igual ao nome do diretório do plugin (sem espaços, lowercase)
- `repo`: caminho `<owner>/<repo>` no Forgejo
- `logo_url: null`: faz o Mindplace buscar `logo.png` da raiz do repo automaticamente
Commit direto pelo Forgejo (Commit Changes na interface).
### 3.2 — Validação
No GLPI do cliente (com mindplace instalado e licença ativa), abra a aba **Mind Place**. O novo plugin deve aparecer no grid em até 1 hora (cache TTL). Para forçar refresh imediato, clica no botão de refresh do Mindplace.
### Checklist da Fase 3
- [ ] Entrada adicionada em `plugins.json`
- [ ] Commit feito
- [ ] Plugin aparece no grid do Mindplace
- [ ] Logo carrega corretamente (se `logo.png` existe na raiz do repo)
- [ ] Botão **Download/Install** funciona
---
## Fase 4 — Cadastro de licença no GLPI de produção
Cada cliente recebe um serial único. O serial fica em `glpi_softwarelicenses` do GLPI da Mindtek e é validado pelo `mindtek_validador.php`.
### 4.1 — Criar entrada de licença
No GLPI de produção (`servicedesk.mindtek.com.br`):
1. **Ativos → Licenças de Software → +**
2. Preencher:
- **Nome**: `Licença <Plugin> — <Cliente>` (ex: `Licença Tilesections — Empresa Acme`)
- **Software**: associar ao software do plugin (criar se não existir)
- **Número de série**: gerar hash hex aleatório de 32 caracteres
```bash
openssl rand -hex 16 | tr 'a-z' 'A-Z'
# exemplo: 7C9F1E3A4B5D6F8E2C0A1B3D4E5F6071
```
- **Tipo**: livre (sugestão: `Mindplace Plugin`)
- **Validade**: opcional, conforme contrato
3. Salvar
### 4.2 — Entregar o serial ao cliente
Mensagem padrão para o cliente:
> Olá,
>
> Sua licença do plugin **\<Plugin\>** está ativa. Use o serial abaixo para ativar no Mindplace do seu GLPI:
>
> `7C9F1E3A4B5D6F8E2C0A1B3D4E5F6071`
>
> **Como ativar:**
> 1. No GLPI, acesse **Configurar → Plugins → Mind Place** (ícone de chave)
> 2. Cole o serial no campo **API Token** e salve
> 3. Aguarde o status mudar para "Ativo" (alguns segundos)
> 4. Pronto, o plugin pode ser ativado normalmente
### Checklist da Fase 4
- [ ] Software cadastrado em `Ativos → Software` (se primeira vez)
- [ ] Licença criada com serial único de 32 chars hex
- [ ] Serial enviado ao cliente
- [ ] Cliente confirmou ativação no Mindplace dele
---
## Fase 5 — Manutenção contínua
### Lançar nova versão do plugin
1. Atualizar `version` no `setup.php` (ex: `1.0.0``1.1.0`)
2. Commit + push para `main` no Forgejo
3. Criar nova release no Forgejo com tag `v1.1.0` + novo ZIP
4. **Não** atualiza `plugins.json` — o Mindplace pega automaticamente a release mais recente
5. Clientes verão o botão **Atualizar** no Mindplace dentro de 1 hora (cache TTL)
### Revogar licença de um cliente
1. No GLPI de produção: **Ativos → Licenças de Software** → encontrar o serial
2. Marcar `is_deleted = 1` (mover para a lixeira) **OU** deletar permanentemente
3. No próximo cron (1x por dia) ou na próxima vez que o cliente salvar a config do mindplace, `License::cronCheck()` vai retornar `expired`
4. Plugin do cliente desativa automaticamente
### Verificar status de licença de um cliente
```sql
-- No GLPI de produção
SELECT id, name, serial, is_deleted, completename
FROM glpi_softwarelicenses
WHERE serial = 'SERIAL_DO_CLIENTE';
```
Ou consultar o validador diretamente:
```bash
curl "https://servicedesk.mindtek.com.br/mindtek_validador.php?token=SERIAL_DO_CLIENTE"
# {"status":"active"} ou {"status":"expired"}
```
---
## Referências cruzadas
- [KB-PLUGIN-011](KB-PLUGIN-011-mindplace-release-zip-procedure.md) — procedimento de ZIP via GitHub (legado, antes da migração para Forgejo)
- [KB-PLUGIN-012](KB-PLUGIN-012-helpdesk-tiles-extension-patterns.md) — padrões para estender o sistema de tiles do Helpdesk
## Script de automação
| Arquivo | Localização | Função |
|---|---|---|
| `mindplace-release.sh` | `bin/mindplace-release.sh` na raiz do projeto GLPI11 | Script Bash que automatiza as Fases 25 completas via API do Forgejo |
## Arquivos críticos
| Arquivo | Localização | Função |
|---|---|---|
| `mindtek_validador.php` | `/var/www/html/glpi/public/` no servidor de produção | Endpoint que valida serials contra `glpi_softwarelicenses` |
| `License.php` | `plugins/mindplace/src/` no GLPI do cliente | Cron + `isValid()` que outros plugins consultam |
| `plugins.json` | repo `rodolpho.lopes/mindplace` no Forgejo | Catálogo do Mindplace |
| `secrets.php` | NÃO USAR no setup atual com Forgejo (era para PAT do GitHub) | — |
## Notas importantes
- **Forgejo não tem rate limit** — não precisa de token de autenticação. Repositórios são públicos
- **`License::isValid()` é zero-latency** — lê cache no banco, não bate em API. Pode ser chamado em cada page load sem custo
- **Cron de validação roda 1x/dia** — se o cliente quer validar agora, salvar a config do mindplace força o check imediato
- **Sem licença, sem prejuízo** — o kill switch é silencioso; o GLPI continua funcional sem o plugin, sem mensagens de erro

View file

@ -0,0 +1,124 @@
---
id: KB-PLUGIN-014
title: getFromDBByCrit() não limpa $fields em falha — reutilizar a mesma instância vaza estado entre iterações
domain: plugin-dev
tags:
- glpi
- glpi11
- commondbtm
- getfromdbbycrit
- bug
- iteration
- state-leak
status: active
severity: critical
created_at: 2026-05-10
updated_at: 2026-05-10
applies_to:
- GLPI 11.0.x
---
# `getFromDBByCrit()` vaza `$fields` entre iterações quando reutilizado
## Sintoma
Loop que itera sobre uma lista de itens (ex: plugins, entidades, qualquer `CommonDBTM`) reutilizando a mesma instância da classe via `getFromDBByCrit()` — itens que **não existem** no banco aparecem misteriosamente com dados do **item anterior** que foi encontrado.
No caso real do Mindplace: dois plugins (`splititil` e `tilesections`). `splititil` estava instalado, `tilesections` não. Ao renderizar a lista, `tilesections` apareceu com o **estado e ID do `splititil`** — clicar "Habilitar" no `splititil` parecia habilitar o `tilesections` (porque o render reaproveitava os fields).
## Causa
`CommonDBTM::getFromDBByCrit()` retorna `false` quando não encontra o item, **mas não limpa `$this->fields`**. A próxima chamada a `$obj->isNewItem()` ou acesso a `$obj->fields['...']` retorna o estado da **iteração anterior**.
```php
// CommonDBTM::getFromDBByCrit (resumido)
public function getFromDBByCrit(array $crit) {
$iter = $DB->request([
'FROM' => $this::getTable(),
'WHERE' => $crit,
]);
if (count($iter) == 1) {
$row = $iter->current();
return $this->getFromDB($row['id']); // sucesso: carrega fields
}
return false; // ← FALHA: fields NÃO são limpos!
}
```
## Código defeituoso
```php
// ❌ ERRADO — vaza estado entre iterações
$plugin_obj = new Plugin();
foreach ($plugins as &$p) {
$plugin_obj->getFromDBByCrit(['directory' => $p['key']]);
if ($plugin_obj->isNewItem()) {
// tilesections (não instalado) entra aqui só na PRIMEIRA iteração
// se for o primeiro a falhar. Senão, herda fields do anterior.
$p['state'] = Plugin::NOTINSTALLED;
} else {
$p['state'] = (int) $plugin_obj->fields['state']; // ← fields do plugin ERRADO
}
}
```
## Padrões corretos
### Opção 1 — Nova instância por iteração (mais defensivo)
```php
foreach ($plugins as &$p) {
$plugin_obj = new Plugin(); // ← fresh instance, fields zerados
$found = $plugin_obj->getFromDBByCrit(['directory' => $p['key']]);
if (!$found) {
$p['state'] = Plugin::NOTINSTALLED;
continue;
}
$p['state'] = (int) $plugin_obj->fields['state'];
}
```
### Opção 2 — Usar o valor de retorno (mais idiomático)
```php
$plugin_obj = new Plugin();
foreach ($plugins as &$p) {
if (!$plugin_obj->getFromDBByCrit(['directory' => $p['key']])) {
$p['state'] = Plugin::NOTINSTALLED;
continue;
}
$p['state'] = (int) $plugin_obj->fields['state'];
}
```
A Opção 2 é mais idiomática, mas **só funciona** se você nunca acessar `$plugin_obj->fields` no branch de falha. Se houver qualquer chance de o código acessar `$plugin_obj` depois de uma falha, prefira a Opção 1.
## Como nunca usar `isNewItem()` após `getFromDBByCrit()`
`isNewItem()` retorna `$this->fields['id'] <= 0`. Como `getFromDBByCrit()` não limpa `fields`, **`isNewItem()` mente** após um miss. Use sempre o valor de retorno do próprio `getFromDBByCrit()`:
```php
// ❌
$obj->getFromDBByCrit($crit);
if ($obj->isNewItem()) { ... } // não confiável!
// ✅
if (!$obj->getFromDBByCrit($crit)) { ... }
```
## Comportamento de métodos relacionados
| Método | Limpa `$fields` em falha? |
|---|---|
| `getFromDB($id)` | ✅ sim — `fields = []` |
| `getFromDBByCrit($crit)` | ❌ não |
| `getFromDBByQuery($query)` | ❌ não (em geral) |
| `find($criteria)` | n/a — retorna array, não muda estado |
Quando em dúvida, **sempre instancie um objeto novo** por iteração. O overhead é desprezível e elimina toda uma classe de bugs sutis.
## Onde isso pegou na vida real
`docker/glpi/plugins/mindplace/src/MarketplaceView.php::showPage()` — bug surgiu quando o catálogo do Mindplace passou a ter mais de um plugin. O segundo plugin (não instalado) "herdava" o estado do primeiro (instalado), causando comportamento inexplicável: clicar Habilitar num plugin parecia ativar outro.
Corrigido em `mindplace v1.0.7` instanciando `new Plugin()` por iteração.

View file

@ -0,0 +1,94 @@
---
id: KB-PLUGIN-015
title: querySelector('.row') colide com elementos de outros plugins no Helpdesk portal
domain: plugin-dev
tags:
- glpi
- glpi11
- helpdesk
- tilesections
- dom
- selectors
- bootstrap
- cross-plugin-interference
status: active
severity: high
created_at: 2026-05-10
updated_at: 2026-05-10
applies_to:
- GLPI 11.0.x
---
# `querySelector('.row')` colide com outros plugins no Helpdesk portal
## Sintoma
Plugin funciona perfeitamente no ambiente de desenvolvimento mas falha **silenciosamente** em produção — não há erro no console, não há AJAX falhando, mas o DOM não é manipulado. A causa é difícil de descobrir porque o servidor retorna os dados corretos e o JS chega a rodar.
No caso real: `tilesections/public/js/helpdesk_home.js` em produção não reorganizava os tiles em categorias. Em dev funcionava. A diferença era a presença de **outro plugin** (`news_alert`) em produção que injetava um elemento com classe `row` antes dos tiles.
## Causa
A `container-xl` do portal do Helpdesk pode conter múltiplos elementos com class `row`:
```html
<div class="container-xl">
<table class="central">
<tbody><tr><td>
<!-- Plugin news_alert injeta isto via hook DISPLAY_CENTRAL -->
<div class="plugin_news_alert-container row align-items-stretch"></div>
</td></tr></tbody>
</table>
<!-- A row dos tiles -->
<div class="row">
<div class="col-12 col-sm-6 col-md-4 d-flex">
<a class="card" href="...">...</a>
</div>
</div>
</div>
```
Código defeituoso:
```js
const container = tilesSection.querySelector('.container-xl');
const tilesRow = container.querySelector('.row'); // ❌ pega o do news_alert
```
`querySelector` faz busca em profundidade e retorna o **primeiro** match na árvore — incluindo elementos aninhados. O `plugin_news_alert-container` está dentro da `<table>` que vem **antes** da row dos tiles, então é encontrado primeiro.
Depois disso, `tilesRow.querySelectorAll(':scope > div')` retorna zero (o container do news plugin está vazio), e a função desiste sem fazer nada.
## Padrão correto
Usar `:scope > .row` para garantir que apenas **filhos diretos** sejam considerados:
```js
const tilesRow = container.querySelector(':scope > .row'); // ✅ só filhos diretos
```
`:scope` referencia o elemento de partida da query (no caso, `container`). `:scope > .row` significa "row que é filha direta de container".
## Lições
- **Nunca confie que sua produção tem a mesma estrutura DOM do dev**. Plugins de terceiros (news, dashboards, customizações antigas) injetam elementos em pontos imprevisíveis.
- Para qualquer seletor que dependa de hierarquia, **prefira `:scope >` ou caminhos específicos** ao invés de buscas globais com classes Bootstrap genéricas como `.row`, `.card`, `.col-*`.
- Quando funcionar local e não em produção, **não assuma diferenças de container/Docker**. Inspecione o DOM real do cliente. O usuário pode rodar no console:
```js
document.querySelector('.tiles-banner .container-xl').innerHTML.substring(0, 600)
```
e mandar o resultado.
## Hierarquia de especificidade recomendada
Para seletores de containers em plugins GLPI, em ordem de robustez:
1. **`[data-glpi-*]` atributos** — quando GLPI fornece (ex: `[data-glpi-helpdesk-config-tiles]`). Mais estável.
2. **`:scope > .classe`** — quando só filhos diretos importam.
3. **Caminho específico** — ex: `.tiles-banner > .container-xl > .row`.
4. **`.row` solto** — **evitar**. Praticamente garante colisão com outros plugins.
## Onde isso pegou na vida real
`docker/glpi/plugins/tilesections/public/js/helpdesk_home.js::reorganizeTiles()` — corrigido em `tilesections v1.1.2`. O bug não era reproduzível em dev porque o ambiente Docker não tinha o plugin `news_alert` instalado.

View file

@ -0,0 +1,112 @@
---
id: KB-PLUGIN-016
title: "Bypass de CSRF e Autenticação Nativa (Bearer Token) para APIs Customizadas no GLPI 11"
domain: plugin-dev
tags:
- glpi-11
- csrf
- api
- jwt
- oauth
- auth
- stateless
status: active
severity: high
created_at: 2026-05-20
updated_at: 2026-05-20
applies_to:
- GLPI 11+
- Plugins com APIs customizadas
---
# Bypass de CSRF e Autenticação Nativa para APIs Customizadas no GLPI 11
## Contexto
No GLPI 11, a segurança do roteador principal e do Kernel do Symfony foi reforçada. A constante `GLPI_USE_CSRF_CHECK` passou a ser ignorada por motivos de segurança, e todas as rotas (incluindo as de compatibilidade "LegacyRoutes" mapeadas via `public/index.php`) sofrem interceptação do `CheckCsrfListener` se não forem explicitamente isentas.
Isso bloqueia requisições `POST` (como payloads JSON-RPC) feitas para endpoints customizados criados dentro de plugins (ex.: `ajax/mcp.php` ou `public/index.php`), retornando erro de "Ação não permitida" ou redirecionamento de sessão, impossibilitando a criação de APIs independentes dentro dos plugins usando a infraestrutura convencional sem modificações.
## Causa Raiz
O `CheckCsrfListener` no Kernel do GLPI 11 bloqueia requisições `POST` sem token CSRF válido a menos que o endpoint seja registrado como um **recurso stateless** (sem estado). O GLPI Web Frontend requer que as requisições possuam estado (Session Cookies), bloqueando chamadas puras de API (ex: Server-to-Server via MCP) que dependem exclusivamente de Cabeçalhos de Autorização (`Authorization: Bearer <TOKEN>`).
## Resolução e Arquitetura Recomendada
A solução exige dois passos: isentar o endpoint customizado do controle de estado (e consequentemente do CSRF) e implementar a validação nativa do JWT dentro do escopo do endpoint, injetando o usuário autenticado na sessão corrente.
### 1. Registrar a Rota como Stateless
No arquivo `setup.php` do seu plugin, dentro da função `plugin_init_seuplugin()`, registre o script PHP (ex: `ajax/mcp.php`) como uma rota stateless através do `SessionManager`. Essa regra será lida pelo `SessionManager::isResourceStateless()`:
```php
function plugin_init_mcprotocol() {
global $PLUGIN_HOOKS;
$PLUGIN_HOOKS['csrf_compliant']['mcprotocol'] = true;
// Registra o endpoint da API como stateless para ignorar o CSRF e Session Checks no GLPI 11
if (class_exists('\Glpi\Http\SessionManager') && method_exists('\Glpi\Http\SessionManager', 'registerPluginStatelessPath')) {
\Glpi\Http\SessionManager::registerPluginStatelessPath('mcprotocol', '#ajax/mcp\.php#');
}
}
```
### 2. Validar JWT e Injetar na Sessão (Herança de Permissões)
No seu arquivo de endpoint da API (`ajax/mcp.php`), carregue as dependências nativas (incluindo `inc/includes.php`). Valide o Bearer token JWT diretamente usando a chave pública nativa do GLPI (localizada em `GLPI_CONFIG_DIR . '/oauth.pub'`) e depois estabeleça as variáveis globais de sessão para garantir que todas as chamadas subjacentes das classes GLPI (ex: `Ticket`, `Project`) atuem em nome do usuário correto.
```php
// ajax/mcp.php
$AJAX_INCLUDE = 1;
define('GLPI_ROOT', '../../..');
// Hack de bypass: força o método como GET antes do includes.php carregar
// Para evitar possíveis verificações antecipadas em versões transicionais
$realMethod = $_SERVER['REQUEST_METHOD'];
$_SERVER['REQUEST_METHOD'] = 'GET';
include (GLPI_ROOT . "/inc/includes.php");
$_SERVER['REQUEST_METHOD'] = $realMethod;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
header('Content-Type: application/json');
// 1. Extração do Token
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? '';
if (empty($authHeader) || !preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
echo json_encode(["error" => "Unauthorized - Bearer token missing"]);
exit;
}
$jwt = $matches[1];
// ATENÇÃO: GLPI 11 exporta a chave pública no diretório principal de configs
$publicKeyPath = GLPI_CONFIG_DIR . '/oauth.pub';
try {
// 2. Validação Nativa
$publicKey = file_get_contents($publicKeyPath);
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
$userId = $decoded->sub ?? null;
if (!$userId) {
throw new Exception("Token missing 'sub' claim");
}
// 3. Injeção de Sessão (Herança de LGPD / Permissões)
$_SESSION['glpiID'] = $userId;
$_SESSION['glpiname'] = "API User";
// É recomendado carregar outras flags se for interagir com entidades complexas
} catch (Exception $e) {
echo json_encode(["error" => "Invalid token"]);
exit;
}
// 4. Fluxo normal da API a partir daqui
$payload = json_decode(file_get_contents('php://input'), true);
echo json_encode(["status" => "success", "user_id" => $userId]);
```
## Aprendizados Chave
- O `scimmind` foi desenhado com o bypass baseado em `GLPI_USE_CSRF_CHECK`, o qual está obsoleto no GLPI 11.
- A diretriz recomendada pela Teclib (GLPI 11) para pontos de acesso isolados é o uso do `SessionManager::registerPluginStatelessPath`.
- Não adianta validar a requisição com sucesso e não definir o `$_SESSION['glpiID']`. O ecosistema do GLPI é intrinsicamente atrelado a `$_SESSION`, e falhar em instanciá-lo antes de acionar métodos da classe (como buscar itens de DB) levará a falhas de acesso e violação das entidades (`Entity`).

View file

@ -0,0 +1,122 @@
---
id: KB-PLUGIN-017
title: "Bug do detector de wrapper directory no instalador Mindplace mutilava nomes de arquivos"
domain: plugin-dev
tags:
- mindplace
- marketplace
- zip
- extractor
- install-bug
status: active
severity: high
created_at: 2026-05-21
updated_at: 2026-05-21
applies_to:
- Plugin Mindplace (ajax/marketplace.php)
- Qualquer plugin distribuído via Mindplace
---
# KB-PLUGIN-017 — Bug do detector de wrapper directory no instalador Mindplace
## Contexto
O extrator de plugins do Mindplace (`docker/glpi/plugins/mindplace/ajax/marketplace.php`) baixa o `.zip` da release do GitHub, detecta um "wrapper directory" no topo e remove esse prefixo dos demais entries antes de extrair para `marketplace/<plugin>/`.
Quando o ZIP de release de um plugin é gerado **sem** um wrapper directory consistente, a heurística antiga aceitava qualquer pasta top-level como wrapper e **mutilava** os paths de todos os outros entries.
## Sintoma
A instalação via Mindplace completa "com sucesso" mas o GLPI marca o plugin como **não-carregável** silenciosamente. Listando o diretório de instalação aparecem arquivos com nomes truncados:
```
marketplace/mcprotocol/
├── .php ← deveria ser hook.php
├── .png ← deveria ser logo.png
├── p.php ← deveria ser setup.php
├── est.php ← deveria ser apirest.php
├── ic/ ← deveria ser public/
│ ├── index.php
│ └── logo.png
├── t/ ← deveria ser front/
└── Boot.php ← deveria ser src/Boot.php
```
O conteúdo dos arquivos está **íntegro** — apenas o nome do arquivo (caminho) foi mutilado.
## Causa Raiz
A detecção em `ajax/marketplace.php` (versão buggy):
```php
$first = $zip->getNameIndex(0);
$prefix = '';
if ($first !== false && str_ends_with($first, '/') && substr_count(rtrim($first, '/'), '/') === 0) {
$prefix = $first; // aceito como wrapper sem validar
}
```
A heurística aceitava como wrapper **qualquer pasta top-level que fosse o primeiro entry do ZIP**, mesmo que essa pasta não fosse realmente um wrapper (envelopando todos os demais).
Quando o ZIP do MCProtocol foi gerado pelo script `bin/mindplace-release.sh` (versão anterior, que fazia `cd "$PLUGIN_DIR" && zip -r "$ZIP_PATH" .` — sem wrapper directory), o primeiro entry era a pasta `inc/` (top-level legítima do plugin). O extrator então removia 4 chars (`inc/`) do começo de **todos** os outros entries:
| Entry no ZIP | substr(_, 4) | Resultado em disco |
|---|---|---|
| `hook.php` | `.php` | `.php` |
| `logo.png` | `.png` | `.png` |
| `setup.php` | `p.php` | `p.php` |
| `apirest.php` | `est.php` | `est.php` |
| `public/` | `ic/` | pasta `ic/` |
| `public/index.php` | `ic/index.php` | dentro de `ic/` |
| `front/` | `t/` | pasta `t/` |
| `src/Boot.php` | `Boot.php` | top-level |
| `inc/mcp.class.php` | `mcp.class.php` | top-level |
## Resolução (fix aplicado em maio/2026)
O detector agora **valida que o candidato a wrapper é prefixo de todos os entries** antes de aceitá-lo. Se algum entry não começar com o candidato, o prefix fica vazio e a extração preserva a estrutura original do ZIP.
```php
// Detect common top-level wrapper directory.
// The wrapper is only valid if EVERY entry starts with it; otherwise a
// top-level folder like "inc/" would be mistaken for a wrapper and the
// substr() below would mutilate the remaining entries' paths.
$prefix = '';
$count = $zip->count();
$candidate = $count > 0 ? $zip->getNameIndex(0) : false;
if (
$candidate !== false
&& str_ends_with($candidate, '/')
&& substr_count(rtrim($candidate, '/'), '/') === 0
) {
$allMatch = true;
for ($i = 1; $i < $count; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false || !str_starts_with($name, $candidate)) {
$allMatch = false;
break;
}
}
if ($allMatch) {
$prefix = $candidate;
}
}
```
Arquivo: `docker/glpi/plugins/mindplace/ajax/marketplace.php` (linhas ~91-114).
## Como diagnosticar uma instalação afetada
1. Listar `docker/glpi/marketplace/<plugin>/` — se aparecerem arquivos com nomes começando por `.` (`.php`, `.png`), arquivos com nomes incompletos (`p.php`, `est.php`), ou pastas com nomes esquisitos (`ic/`, `t/`), o bug está presente.
2. Confirmar inspecionando o `.zip` original: `unzip -l <plugin>.zip | head` — se o primeiro entry **não** é um wrapper directory que contém todos os demais, o ZIP é vulnerável a esta versão do bug.
## Como evitar (em plugins novos)
Gerar releases com **wrapper directory consistente** — ver [[KB-PLUGIN-018]] para a workflow recomendada.
## Veja também
- [KB-PLUGIN-011](KB-PLUGIN-011-mindplace-release-zip-procedure.md) — Procedimento de release do Mindplace
- [KB-PLUGIN-013](KB-PLUGIN-013-mindplace-plugin-publication-runbook.md) — Runbook de publicação de plugin no Mindplace
- [KB-PLUGIN-018](KB-PLUGIN-018-plugin-release-zip-wrapper-convention.md) — Convenção de wrapper directory para ZIPs de release

View file

@ -0,0 +1,141 @@
---
id: KB-PLUGIN-018
title: "Convenção de wrapper directory na geração de ZIP de release de plugin (script mindplace-release.sh)"
domain: plugin-dev
tags:
- mindplace
- forgejo
- release
- zip
- wrapper
- mindplace-release-sh
status: active
severity: high
created_at: 2026-05-21
updated_at: 2026-05-21
applies_to:
- Qualquer plugin publicado via bin/mindplace-release.sh
- Plugins distribuídos pelo catálogo Mindplace
related:
- KB-PLUGIN-013
- KB-PLUGIN-017
---
# KB-PLUGIN-018 — Convenção de wrapper directory para ZIPs de release
## Contexto
ZIPs de release de plugin GLPI distribuídos via Mindplace devem ter um **wrapper directory consistente** no topo (uma pasta com o nome do plugin envolvendo todos os arquivos). Sem isso, o extrator do Mindplace podia mutilar paths em versões anteriores ao fix descrito em [KB-PLUGIN-017](KB-PLUGIN-017-mindplace-zip-wrapper-detector-bug.md).
Mesmo após o fix do detector, manter o wrapper continua sendo a forma "correta" e mais robusta: dá ao extrator um prefix bem-definido para remover, e remove ambiguidade na inspeção do ZIP.
## Estrutura esperada
```
mcprotocol-1.0.3.zip
└── mcprotocol/ ← wrapper directory (nome = key do plugin = basename do diretório)
├── setup.php
├── hook.php
├── ajax/
│ └── mcp.php
├── src/
│ ├── Boot.php
│ └── Server.php
└── public/
└── logo.png
```
Verificação rápida:
```bash
unzip -l <plugin>-<ver>.zip | head
# Primeiro entry DEVE ser "<plugin>/"
# Todos os demais entries DEVEM começar com "<plugin>/"
```
## Fluxo oficial (automatizado)
Use o script `bin/mindplace-release.sh` documentado em [KB-PLUGIN-013](KB-PLUGIN-013-mindplace-plugin-publication-runbook.md). Desde 2026-05-21, a Fase 3 do script gera ZIPs com wrapper directory automaticamente — rodando `zip` do **diretório pai** do plugin:
```bash
# Trecho relevante de bin/mindplace-release.sh (Fase 3)
PARENT_DIR="$(dirname "$PLUGIN_DIR")"
PLUGIN_BASENAME="$(basename "$PLUGIN_DIR")"
cd "$PARENT_DIR"
zip -r "$ZIP_PATH" "$PLUGIN_BASENAME" \
--exclude "$PLUGIN_BASENAME/.git*" \
--exclude "$PLUGIN_BASENAME/node_modules/*" \
--exclude "$PLUGIN_BASENAME/vendor/*" \
--exclude "*.DS_Store" \
--exclude "*.zip" \
-q
# Sanity check: aborta se wrapper não aparecer no topo
FIRST_ENTRY=$(unzip -Z1 "$ZIP_PATH" | head -1)
if [ "$FIRST_ENTRY" != "$PLUGIN_BASENAME/" ]; then
err "ZIP gerado sem wrapper directory esperado..."
fi
```
O sanity check no fim falha rápido se algum dia o ZIP voltar a sair sem wrapper.
## Geração manual (fallback)
Se precisar gerar localmente fora do script:
```bash
# A partir do diretório PAI da pasta do plugin
cd docker/glpi/plugins
zip -r mcprotocol-1.0.3.zip mcprotocol/ \
-x "mcprotocol/.git*" \
-x "*.zip" \
-x "mcprotocol/node_modules/*" \
-x "mcprotocol/vendor/*"
```
**Não** fazer `cd plugin && zip -r ...zip .` — esse padrão produz ZIP sem wrapper e era o bug histórico do script.
## .gitignore padrão do plugin
Para que o ZIP não inclua arquivos sensíveis ou de teste, garantir `.gitignore` na raiz do plugin:
```gitignore
# Dependências
vendor/
node_modules/
# macOS
.DS_Store
**/.DS_Store
# IDE
.idea/
.vscode/
# Logs
*.log
# Build artifacts
*.zip
# Arquivos de teste (não publicar)
ajax/test*.php
ajax/mcp_test.php
front/test*.php
```
O script `mindplace-release.sh` excludes `.git*` por padrão; o `.gitignore` em si pode ser commitado e publicado sem problema.
## Erros comuns
| Sintoma | Causa | Fix |
|---|---|---|
| Após instalar via Mindplace, arquivos com nomes mutilados (`.php`, `p.php`, pastas `ic/`, `t/`) | ZIP gerado sem wrapper + Mindplace com detector buggy | Atualizar Mindplace ([KB-PLUGIN-017]) **e** regerar ZIP com wrapper |
| Após instalar, pasta `mcprotocol/mcprotocol/` aninhada | ZIP com wrapper mas extrator versão muito antiga, ou wrapper com nome diferente do `key` | Conferir que o basename do diretório bate com o `key` em plugins.json |
| Release não aparece no Mindplace | Release sem asset `.zip` no Forgejo | Verificar que a Fase 4 do script subiu o asset; tem rate limit ou erro? |
## Veja também
- [KB-PLUGIN-011](KB-PLUGIN-011-mindplace-release-zip-procedure.md) — Procedimento de release **legado** via GitHub (deprecated)
- [KB-PLUGIN-013](KB-PLUGIN-013-mindplace-plugin-publication-runbook.md) — Runbook completo de publicação no Mindplace via Forgejo
- [KB-PLUGIN-017](KB-PLUGIN-017-mindplace-zip-wrapper-detector-bug.md) — Bug do detector de wrapper (motivação histórica)

View file

@ -0,0 +1,23 @@
# KB-PLUGIN-019: Arquitetura BFF (Backend For Frontend) e Segurança via API no GLPI 11 (Plugin MCProtocol)
**Domain:** plugin-dev
**Tags:** glpi, glpi11, mcp, api, rbac, security, bff
## 1. Segurança e Escopo de Dados (RBAC)
O uso obrigatório da API REST para listagens de dados (ao invés de consultas SQL diretas via `global $DB`) garante a aplicação das restrições de permissão nativas do GLPI.
- **Risco do SQL Direto:** Ignora regras de Entidades, Perfis, e Direitos do GLPI (`getEntitiesRestrictCriteria()`). Pode causar vazamento de dados sensíveis (ex: chamados confidenciais) em clientes MCP.
- **Vantagem da API:** O cliente MCP usa o próprio token OAuth vinculado à sessão de um usuário. O motor da API V2 obrigatoriamente aplica as restrições de Entidade e Perfil, e resolve relacionamentos (HATEOAS).
## 2. A Camada "Backend For Frontend" (BFF)
Ferramentas MCP genéricas (ex: `glpi_get_items`) forçam o LLM a "adivinhar" rotas exatas (`/Assistance/Ticket`), consumindo tokens extras e elevando o risco de alucinações.
- **Solução (View Semântica):** O plugin GLPI exporta ferramentas altamente especializadas (ex: `glpi_ticket_search_by_status`).
- **Implementação:** O LLM envia parâmetros simples (`status: 4`), enquanto o código PHP da ferramenta empacota isso na sintaxe estrita da API do GLPI (ex: `['searchText' => ['status' => 4]]`), direcionando o request cURL internamente.
## 3. Roteamento e HTTP Loopback no cURL Interno
Para que o plugin consiga consumir a própria API REST do GLPI sem passar pelo proxy/firewall de borda, o request cURL interno do plugin exige tratamentos:
1. **Host Correto:** Deve usar o `$CFG_GLPI['url_base']` (ex: `127.0.0.1` associado ao virtual host) em vez de domains externos que causariam gargalo de NAT.
2. **Redirecionamento:** `CURLOPT_FOLLOWLOCATION => true` deve estar ativo para prever ambientes onde Apache/Nginx force 301 de HTTP para HTTPS.
3. **SSL Local:** `CURLOPT_SSL_VERIFYPEER => false` é obrigatório para não falhar caso os certificados locais/SNI não correspondam à interface loopback.
## 4. O Sistema de Buscas do GLPI (V2 API)
Para realizar buscas exatas em colunas específicas (ex: "Apenas chamados com status X"), o parâmetro `searchText` genérico da API V2 tem limitações. Enviar `searchText=status=4` gera uma busca texto genérica. A solução correta via código PHP é enviar o `searchText` em formato array (`'searchText' => ['status' => 4]`), o que induz o GLPI a procurar a correspondência exata.

View file

@ -0,0 +1,33 @@
# KB-PLUGIN-020: Roadmap de Evolução do MCProtocol (Views Semânticas API)
**Domain:** plugin-dev
**Tags:** glpi, glpi11, mcp, api, roadmap, bff
Usando a ferramenta `glpi_ticket_search_by_status` como modelo de arquitetura "Backend For Frontend", as próximas atualizações do plugin focarão em mapear o restante do ecossistema de chamados e ativos do GLPI para ferramentas simples, seguras e com baixo custo de tokens.
## 1. Gestão de Chamados (Assistance/Ticket)
- `glpi_ticket_get_pending_approvals`
- **Objetivo:** Retornar os chamados aguardando aprovação do usuário atual.
- **Uso de API:** Filtro por status=Aguardando Aprovação e amarrado à Entidade.
- `glpi_ticket_assign_to_me`
- **Objetivo:** Assumir a autoria técnica de um chamado com apenas o ID.
- **Uso de API:** Aciona o PATCH nativo descobrindo o usuário da sessão.
- `glpi_ticket_add_private_note`
- **Objetivo:** Adicionar acompanhamento oculto do usuário comum.
- **Uso de API:** POST com `is_private = 1`.
## 2. Gestão de Ativos (Assets)
- `glpi_computer_get_unassigned`
- **Objetivo:** Listar computadores em estoque (sem usuário/localização).
- **Uso de API:** Busca em `/Assets/Computer` com filtro de status de estoque e sem vinculação.
- `glpi_user_get_my_assets`
- **Objetivo:** Retornar a lista de ativos que o usuário solicitante possui.
- **Uso de API:** Busca computadores e periféricos vinculados ao ID da sessão.
## 3. Gestão de Projetos (Tools)
- `glpi_project_get_open_tasks`
- **Objetivo:** Listar tarefas de projeto do usuário.
- **Uso de API:** Rota de projetos filtrando tarefas pendentes.
## Benefícios Contínuos
O principal foco é: **Zero adivinhação de rotas pelo LLM, 100% de uso da engine RBAC nativa, JSON Limpo.**

View file

@ -0,0 +1,68 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Knowledge Record Metadata",
"type": "object",
"required": [
"id",
"title",
"domain",
"tags",
"status",
"severity",
"created_at",
"updated_at",
"applies_to"
],
"properties": {
"id": {
"type": "string",
"pattern": "^KB-[A-Z]+-[0-9]{3}$"
},
"title": {
"type": "string",
"minLength": 8
},
"domain": {
"type": "string",
"enum": ["infrastructure", "security", "plugin-dev", "process", "ci-cd"]
},
"tags": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"status": {
"type": "string",
"enum": ["active", "deprecated", "draft"]
},
"severity": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"created_at": {
"type": "string",
"format": "date"
},
"updated_at": {
"type": "string",
"format": "date"
},
"applies_to": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"related_records": {
"type": "array",
"items": {
"type": "string",
"pattern": "^KB-[A-Z]+-[0-9]{3}$"
}
}
},
"additionalProperties": true
}