Skip to content

Vigiante — Documentação de Customização

Registro completo das alterações realizadas no projeto Vicon SAGA original para transformá-lo no Vigiante/PICAPS (Fiocruz Brasília), incluindo correções de bugs herdados, identidade visual, autenticação, compatibilidade com MySQL 8, infraestrutura Docker, deploy em VPS Oracle Cloud e funcionalidades novas.

Projeto base: Vicon SAGA (PHP 7.4 + Apache + MySQL 8, encoding ISO-8859-1) Rebranding: Vicon SAGA → Vigiante Organização: Fiocruz Brasília + UnB + SES-DF Data: Abril–Maio 2026


Sumário

  1. Identidade visual
  2. Conteúdo e textos
  3. Roteamento e URLs
  4. Encoding e caracteres inválidos
  5. Autenticação e criação de projeto
  6. Compatibilidade com MySQL 8
  7. Logos, paletas e SVG
  8. Importação de dados CSV e XLSX
  9. Upload de arquivos e permissões
  10. Exportação CSV
  11. Download de arquivos — output.php
  12. QR Code
  13. Página de compartilhamento de formulário
  14. Google Maps API
  15. Infraestrutura Docker
  16. Deploy em VPS Oracle Cloud
  17. Script de deploy automatizado
  18. Questões pendentes e notas de manutenção

Identidade visual

Cores

  • Primária: #00427e (azul Vigiante)
  • Gradiente navbar site público: #002648 → #003a6e
  • Gradiente navbar admin (install.php): #256aa0 → #3788c4
  • Secundária / sucesso: #008B44 (verde)
  • Destaque logo: #FF5B04 (laranja)

Logos

  • img/logo.svg — Logo completa (símbolo + texto "Vigiante" laranja)
  • img/logo-symbol.svg — Símbolo sem texto, usado como default de projetos sem logo
  • img/logofav.png — Favicon 512×512

Backgrounds

Originais substituídos por gradientes claros neutros: - img/backgrounds/1.jpg, 1-150x150.jpg, 1-small.jpg, 1b.jpg, 1b-150x150.jpg - img/background-pattern-gray.png - img/backgrounds/vigiante-bg.jpg — novo background default

Alteração em engine/project.php:

public $strProjectImageNoLogo       = 'img/logo-symbol.svg';
public $strProjectImageNoBackground = 'img/backgrounds/vigiante-bg.jpg';

Fontes

Removida a Bauhaus93 de todos os títulos. Substituída por Arial, Helvetica, sans-serif.

css/main.css:

.project-title {
    font-family: Arial, Helvetica, sans-serif;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0;
}

site/projects.php:

.project-info .title {
    font-family: Arial, Helvetica, sans-serif;
    font-size: 15px;
    font-weight: 600;
}

Página install.php (administração)

CSS para distinguir visualmente da área pública:

.navbar-inner {
    background-color: #2d7bb6 !important;
    background-image: linear-gradient(to bottom, #256aa0, #3788c4) !important;
}

Conteúdo e textos

Página Sobre (site/sobre.php)

Criada do zero com: histórico institucional, contato, equipe Vicon SAGA (Jorge Xavier, Tiago Marino, Gabriel Lousada) e equipe Vigiante (Fernanda Machiner, Jeferson Martins de Castro, Marcelo Souza de Jesus, Rui Ogawa).

Fotos em img/site/team/{fernanda,jeferson,marcelo}.png + rui.jpeg.

Home (site/home.php)

3 seções: logo PICAPS + "Nossa Atuação" + "Territórios Saudáveis, Sustentáveis e Solidários". Fundo branco uniforme em todas as seções.

Ajuda (help/index.php)

  • Removido: "Vicon SAGA Mobile" (menu + submenu)
  • Renomeado: "Criação e Consulta Vicon Web" → "Criação e Consulta"
  • Replacement chars (EF BF BD) → • e ×
  • Item "Mobile" removido

Páginas removidas

contact.php e download.php (obsoletas)


Roteamento e URLs

.htaccess raiz

# Desativado (mascarava erros 404 como home)
#ErrorDocument 404 /site/home

# Adicionado: URLs /site/ bypassam regras da raiz
RewriteRule ^site/ - [L]

# QR Code público
RewriteRule ^qr/([0-9]+)$ qr.php?idForm=$1 [L,NC]

site/.htaccess

Adicionada rota ^sobre$ e catch-all:

RewriteRule ^sobre$ index.php?s=sobre [L,NC]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([a-zA-Z0-9_-]+)$ index.php?s=$1 [L,NC,QSA]

Router site/index.php

Trocado file_exists($_GET['s'].'.php') por file_exists(__DIR__.'/'.$_GET['s'].'.php'). Adicionado fallback via parse_url() quando $_GET['s'] não chega.


Encoding e caracteres inválidos

connection.inc

Convertido de CR (Mac Classic) para LF — PHP lia o arquivo todo como uma linha.

Conversor global UTF-8 → ISO-8859-1 em ajax.php

Browsers enviam UTF-8; o sistema é ISO-8859-1. Sem conversão, "Saúde" virava "Saúde" no banco.

function _utf8ToLatin1Recursive(&$value) {
    if (is_array($value)) {
        foreach ($value as &$v) _utf8ToLatin1Recursive($v);
    } elseif (is_string($value) && mb_check_encoding($value, 'UTF-8')) {
        $converted = @mb_convert_encoding($value, 'ISO-8859-1', 'UTF-8');
        if ($converted !== false) $value = $converted;
    }
}
_utf8ToLatin1Recursive($_GET);
_utf8ToLatin1Recursive($_POST);

Adicionado no topo do ajax.php, antes de qualquer handler.

Visualização correta no terminal MySQL

O banco armazena em ISO-8859-1. Para ver acentos corretamente no terminal:

sudo docker compose exec db mysql --default-character-set=latin1 \
    -u viconsaga -psecret viconsaga

Correção de dados com double-encoding

Para registros gravados antes do fix:

UPDATE project
SET strName = CONVERT(CAST(CONVERT(strName USING BINARY) AS CHAR CHARACTER SET utf8) USING latin1)
WHERE idProject = N;

Autenticação e criação de projeto

Problema original

Sistema gerava senha aleatória de 4 chars e enviava por SMTP. Sem SMTP, usuário ficava sem acesso.

Solução

Usuário define a própria senha no formulário de criação (site/join.php): - Mínimo 8 caracteres, pelo menos 1 letra e 1 número, confirmação obrigatória - Validação client-side antes do AJAX - Senha não mais enviada em texto puro por e-mail - sendMail() envolvida em @ para falhar silenciosamente

engine/project.phpinsertProject()

public function insertProject($strEmail, $strName, $strAlias,
    $blnSendNotificationMail = true, $blnCreateForArchiveRestore = false,
    $strUserPassword = '') {
    $strPassword = ($strUserPassword !== '' && $strUserPassword !== null)
        ? $strUserPassword
        : $this->strings->generateRandomString(8);
}

Reset manual de senha

sudo docker compose exec db mysql -u viconsaga -psecret viconsaga \
  -e "UPDATE user SET strPassword = MD5('novaSenha123') WHERE strEmail = 'x@y.com';"

Compatibilidade com MySQL 8

MySQL 8 ativa only_full_group_by por padrão. Queries com GROUP BY incompleto falhavam silenciosamente, causando telas vazias ou funcionalidades quebradas.

Bug 1 — Projetos não aparecem para usuário logado

Arquivo: engine/user.phpselectUserProjects() Sintoma: página /site/projects vazia quando logado. Correção: alias uu2 nas subqueries, =IN, join explícito, GROUP BY removido.

Bug 2 — Campos de múltipla escolha ausentes no popup

Arquivo: engine/formrecord.phpselectFormRecordData() Sintoma: ao clicar num ponto, campos como "Categoria: Público" não apareciam. Correção: removido GROUP BY frfa.idFormRecordField (dados já únicos pela combinação de chaves).

Bug 3 — Lista lateral de registros não expande

Arquivo: engine/formrecord.phpselectFormRecordsTreeView() Sintoma: lateral mostrava só o nó pai do formulário com contagem, sem registros individuais. Correção: GROUP BY fr.idFormRecord substituído por SELECT externo com ANY_VALUE():

SELECT idFormRecord, ANY_VALUE(idShapeType) AS idShapeType, ANY_VALUE(strValue) AS strValue, ...
FROM (SELECT ... ORDER BY ... LIMIT 500) t
GROUP BY idFormRecord

Bug 4 — Exportação CSV retorna só cabeçalho

Arquivo: engine/formrecord.phpselectFormRecords() Causa: filtro de data comparava DATE(fr.dtLastUpdate) >= '2026-04-20T22:09:58'. MySQL converte o lado esquerdo (DATE) para DATETIME adicionando 00:00:00, resultando em '2026-04-20 00:00:00' >= '2026-04-20 22:09:58' = FALSE — excluindo todos os registros. Correção:

" . ($dtDateFrom ? " AND DATE(fr.dtLastUpdate) >= '" . substr($dtDateFrom, 0, 10) . "'" : "") . "
" . ($dtDateTo   ? " AND DATE(fr.dtLastUpdate) <= '" . substr($dtDateTo,   0, 10) . "'" : "") . "

Padrão de diagnóstico

Se uma tela aparecer vazia sem erro visível, verificar logs do Apache por Error 1055 e procurar GROUP BY na query correspondente:

sudo docker compose logs --tail=100 app 2>&1 | grep -iE "1055|group by"

Logos, paletas e SVG

Bug: SVG causa fatal que trava o botão Salvar

Causa encadeada: 1. strProjectImageNoLogo = 'img/logo-symbol.svg' 2. arrGetImagePallete() em engine/strings.php só suporta GIF/JPEG/PNG 3. Com SVG: imagecopyresampled(null) → fatal error PHP 4. HTML de admManageProject.php truncado antes de div_permissions 5. projectUpdate() no JS tenta .checked de 7 elementos inexistentes → TypeError → botão preso em "Aguarde"

Correção em engine/strings.php:

if (! file_exists($strFileName)) {
    return array(str_replace('#', '', $this->strSiteBaseColor) => 1);
}
$size = @GetImageSize($strFileName);
if (! $size || ! in_array($size[2], array(1, 2, 3))) {
    return array(str_replace('#', '', $this->strSiteBaseColor) => 1);
}

Importação de dados CSV e XLSX

Bug CSV — vírgula decimal vira zero

Causa: o parser protege vírgulas dentro de aspas substituindo por pipe | (evitar quebra no explode). Depois, str_replace(",",".") não converte o pipe. "-29|1728559" não é numérico → coordenada vira 0 → ponto vai para 0°, 0° (costa africana).

Correção em engine/importer.phparrExtractCSVShapeObject():

$lat = trim($lat, " \t\n\r\0\x0B\"'");
$lng = trim($lng, " \t\n\r\0\x0B\"'");
$lat = str_replace(array('|', ','), '.', $lat);
$lng = str_replace(array('|', ','), '.', $lng);

Bug XLSX — tela branca

Causa: XLSX é ZIP internamente. Extensão PHP zip não instalada na imagem base.

Log:

PHP Fatal error: Uncaught Exception: ZipArchive library is not enabled
in /var/www/html/js/excel/Classes/PHPExcel/Reader/Excel2007.php:240

Correção no Dockerfile:

RUN apt-get update && apt-get install -y --no-install-recommends \
        libzip-dev libpng-dev libjpeg-dev libfreetype-dev \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install mysqli pdo pdo_mysql zip gd

Upload de arquivos e permissões

Bug — fotos não salvas

Sintoma: barra de progresso completa, "Salvo" aparece, mas "Arquivos: 0".

Causa: pastas tmp/, projects/, sensors/, support/, archives/ não criadas com permissão para www-data.

Correção no Dockerfile:

RUN mkdir -p /var/www/html/tmp /var/www/html/projects \
             /var/www/html/support /var/www/html/archives /var/www/html/sensors \
 && chown -R www-data:www-data /var/www/html/tmp /var/www/html/projects \
             /var/www/html/support /var/www/html/archives /var/www/html/sensors \
 && chmod -R 775 /var/www/html/tmp /var/www/html/projects \
             /var/www/html/support /var/www/html/archives /var/www/html/sensors

Observação importante: o volume mount ./:/var/www/html no docker-compose.yml sobrescreve as permissões do Dockerfile para pastas que existem no host. Isso afeta funcionalidades que precisam escrever em tmp/ (como QR code). Ver seção QR Code para a solução adotada.


Exportação CSV

Fluxo do sistema

  1. JS chama RCSV (por lote de 500 registros) → gera arquivo temporário com dados
  2. JS chama RCSVM → merge dos arquivos temporários + adiciona cabeçalho → retorna link
  3. JS chama output.php → faz download do arquivo final

Bugs corrigidos

print $file; fantasma em engine/report.phpreportCSVMerge():

// ANTES (bug):
while ($line = fgets($in)) {
    print $file;  // variável inexistente, corrompia o buffer
    fwrite($out, $line);
}
// DEPOIS:
while ($line = fgets($in)) {
    fwrite($out, $line);
}

Variável errada em generateReportCSV():

// ANTES (bug — $arrFormsFields undefined):
$arrFormRecordsDatas = $this->formrecord->selectFormRecordData($arrIDs, $arrFormsFields, false);
// DEPOIS — array() vazio força auto-carregamento com índice idForm correto:
$arrFormRecordsDatas = $this->formrecord->selectFormRecordData($arrIDs, array(), false);

Encoding do CSV: adicionado BOM UTF-8 e conversão ISO-8859-1 → UTF-8 para o Excel abrir sem caracteres quebrados:

fwrite($out, "\xEF\xBB\xBF"); // BOM
fwrite($out, mb_convert_encoding($strHeader, 'UTF-8', 'ISO-8859-1'));
// Para cada linha de dados:
fwrite($out, mb_convert_encoding($line, 'UTF-8', 'ISO-8859-1'));

Download de arquivos — output.php

Bug — erros de header e buffer

Sintoma:

Notice: ob_end_clean(): failed to delete buffer. No buffer to delete in output.php on line 2
Warning: Cannot modify header information - headers already sent

Causa: arquivo iniciava com <? (short open tag). ob_end_clean() gerava Notice quando não havia buffer, que era impresso antes dos header().

Correção:

<?php
while (ob_get_level() > 0) ob_end_clean(); // sem Notice se não há buffer
$_GET['strFileName'] = trim($_GET['strFileName']);
$ext = explode(".", $_GET['strFileName']);
$strExt = isset($ext[1]) ? $ext[1] : '';
// ... headers conforme extensão ...
$fh = fopen($_GET['strFileName'], "rb");
if ($fh) { fpassthru($fh); fclose($fh); }

QR Code

Problema original

Clique no ícone QR fazia download direto sem mostrar a imagem. Múltiplos bugs encadeados foram corrigidos iterativamente.

Bug 1 — 403 do WAF (Nginx Proxy Manager)

O parâmetro strURL=http://vigiante.com/... na query string era bloqueado pela regra "Block Common Exploits" do NPM (interpretado como SSRF).

Solução: o servidor constrói a URL internamente pelo idForm, eliminando o parâmetro da query string.

Bug 2 — QRcode::png() contamina o buffer

O último parâmetro true em QRcode::png($url, $file, 8, 8, true) faz "save AND print", enviando o PNG binário para o output buffer antes do JSON. JSON.parse recebia [PNG binário]{"strFilePathTMP":"..."} e falhava.

Solução: usar QRcode::text() que retorna o frame sem gerar output, e construir a imagem GD manualmente:

$frame = QRcode::text($strQRURL, false, QR_ECLEVEL_H, 8, 4);
if ($frame) {
    $intPix = 8; $intMargin = 4;
    $h = count($frame); $w = strlen($frame[0]);
    $imgW = $w + 2*$intMargin; $imgH = $h + 2*$intMargin;
    $baseImg = imagecreate($imgW, $imgH);
    $colWhite = imagecolorallocate($baseImg, 255, 255, 255);
    $colBlack = imagecolorallocate($baseImg, 0, 0, 0);
    imagefill($baseImg, 0, 0, $colWhite);
    for ($y=0; $y<$h; $y++)
        for ($x=0; $x<$w; $x++)
            if ($frame[$y][$x]=='1') imagesetpixel($baseImg, $x+$intMargin, $y+$intMargin, $colBlack);
    $imgQRCode = imagecreate($imgW*$intPix, $imgH*$intPix);
    imagecopyresized($imgQRCode, $baseImg, 0, 0, 0, 0, $imgW*$intPix, $imgH*$intPix, $imgW, $imgH);
    imagedestroy($baseImg);
    // Capturar PNG em memória
    ob_start(); imagepng($imgQRCode); $strPNGFinal = ob_get_clean();
    imagedestroy($imgQRCode);
    $strBase64 = 'data:image/png;base64,' . base64_encode($strPNGFinal);
    @file_put_contents($arrResult['strFilePathTMP'], $strPNGFinal);
}

Nível QR_ECLEVEL_H (máxima redundância) é necessário quando há overlay no centro.

Bug 3 — encodeArray() corrompe o base64

encodeArray() aplica utf8_encode() em todos os valores, corrompendo o base64. Solução: separar o base64 do array antes de encodificar:

$arrEncoded = $strings->encodeArray($arrResult);
$arrEncoded['strBase64'] = $strBase64; // adicionado DEPOIS do encodeArray
echo json_encode($arrEncoded);

Bug 4 — Permissão de escrita em tmp/ (volume Docker)

O volume mount ./:/var/www/html sobrescreve as permissões do Dockerfile. www-data não consegue escrever em tmp/ via filesystem do host. A abordagem em memória (sem disco) resolve o problema para exibir o QR. O @file_put_contents tenta salvar para o download, falhando silenciosamente se não conseguir.

Bug 5 — Modal com height 0 (HTML aninhado incorretamente)

O modal #modalQRCode estava dentro do #modalEmbed por falta de </div> de fechamento. Com height 0, era invisível mesmo com display:block.

<!-- Modal QR Code -->
<div class="modal hide fade hideSelection" id="modalQRCode"
     style="width:320px;left:50%;transform:translateX(-50%);margin-left:0;border-radius:8px">
  <div class="modal-body" style="text-align:center;padding:16px 20px 8px">
    <button type="button" class="close" data-dismiss="modal"
            style="position:absolute;top:8px;right:12px;font-size:20px">&times;</button>
    <p id="pQRCodeLabel" style="font-weight:600;margin:0 0 10px;font-size:15px;color:#00427e"></p>
    <div id="divQRCodeLoading">...</div>
    <img id="imgQRCode" src="" style="display:none;width:260px;height:260px">
    <div>
      <a href="#" class="btn" data-dismiss="modal">Fechar</a>
      <a id="btnQRCodeDownload" class="btn btn-success" style="display:none" onclick="qrCodeDownload()">Download</a>
    </div>
  </div>
</div>

O transform:translateX(-50%) centraliza em qualquer largura de tela (desktop e celular).

Função JS (js/main.js)

function qrCode(strURL, strLabel, strFormName) {
    strQRCodeFilePath = '';
    document.getElementById('pQRCodeLabel').innerHTML = strFormName || strLabel;
    // ... mostra modal com loading ...
    $.ajax({ url: 'ajax.php?chrAction=HQRC&strLabel=' + encodeURIComponent(strLabel), dataType: 'json' })
    .done(function(arrResult) {
        if (arrResult['strBase64']) {
            document.getElementById('imgQRCode').src = arrResult['strBase64'];
            // ... mostra imagem e botão Download ...
        }
    });
}

Página pública de QR para celular (qr.php)

Acessível sem autenticação em vigiante.com/qr/{idForm}. Exibe o QR em tela cheia (90vw × 90vh) para ser lido por outro celular. Rota adicionada no .htaccess.


Página de compartilhamento de formulário

Problema — layout quebrado em vigiante.com/share/...

Causa: strSiteURL em engine/strings.php usava REQUEST_SCHEME para detectar HTTPS. O Nginx Proxy Manager faz proxy via HTTP internamente, então REQUEST_SCHEME = 'http' dentro do container. Todos os assets (Bootstrap 4 JS, CSS) carregavam com http:// em página HTTPS → Mixed Content → navegador bloqueava JS → tabs sem funcionar, layout quebrado.

Correção em engine/strings.php:

$strHttp = 'http';
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
    $strHttp = 'https';
} elseif (isset($_SERVER['REQUEST_SCHEME']) && strtolower($_SERVER['REQUEST_SCHEME']) === 'https') {
    $strHttp = 'https';
} elseif (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
    $strHttp = 'https';
}

O NPM envia o header X-Forwarded-Proto: https, que agora é verificado primeiro.


Google Maps API

Configuração do projeto no Google Cloud Console

  1. Criar projeto: vigiante-php-picaps
  2. Ativar APIs em APIs & Services → Library:
  3. Maps JavaScript API
  4. Places API (clássica, NÃO a "(New)")
  5. Geocoding API
  6. Maps Static API (necessária para relatório HTML com miniaturas)
  7. Habilitar billing (US$ 200/mês de crédito gratuito)
  8. Criar API Key e restringir por domínio

Configuração da chave

// connection.inc
$strGoogleAPIKey = 'AIzaSy...';

Atenção em updates: connection.inc não está no zip. Sempre preservar antes do deploy:

cp /opt/vigiante/connection.inc ~/connection.inc.vps

Erros típicos

Erro Causa Solução
Marca d'água "For development purposes only" Chave sem billing Habilitar billing
InvalidKeyMapError Chave com typo Verificar connection.inc
RefererNotAllowedMapError URL não listada Adicionar domínio nas restrições
LegacyApiNotActivatedMapError Places API clássica não ativada Ativar Places API (sem "(New)")
Miniaturas em branco no relatório HTML Maps Static API não ativada Ativar Maps Static API

Infraestrutura Docker

Dockerfile (versão final)

FROM php:7.4-apache

RUN apt-get update && apt-get install -y --no-install-recommends \
        libzip-dev libpng-dev libjpeg-dev libfreetype-dev \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install mysqli pdo pdo_mysql zip gd

RUN a2enmod rewrite
COPY docker/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
RUN a2ensite 000-default

COPY . /var/www/html

RUN mkdir -p /var/www/html/tmp /var/www/html/projects \
             /var/www/html/support /var/www/html/archives /var/www/html/sensors \
 && chown -R www-data:www-data /var/www/html/tmp /var/www/html/projects \
             /var/www/html/support /var/www/html/archives /var/www/html/sensors \
 && chmod -R 775 /var/www/html/tmp /var/www/html/projects \
             /var/www/html/support /var/www/html/archives /var/www/html/sensors

COPY docker/custom-php.ini /usr/local/etc/php/conf.d/custom-php.ini
WORKDIR /var/www/html
EXPOSE 80
CMD ["apache2-foreground"]

Observação: as permissões de pastas definidas no Dockerfile valem apenas para arquivos do contexto de build. Pastas montadas via volume (./:/var/www/html) usam as permissões do host.

docker-compose.yml (deploy local)

services:
  app:
    build: { context: . }
    volumes:
      - ./:/var/www/html
      - ./docker/apache-vhost.conf:/etc/apache2/sites-available/000-default.conf:ro
    ports: ["8180:80"]
    depends_on: [db]
    environment:
      - DB_HOST=db
      - DB_NAME=viconsaga
      - DB_USER=viconsaga
      - DB_PASSWORD=secret
    networks: [viconsaga-network]

  db:
    image: mysql:8
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: viconsaga
      MYSQL_USER: viconsaga
      MYSQL_PASSWORD: secret
    volumes:
      - db_data:/var/lib/mysql
      - ./docker/mysql-custom.cnf:/etc/mysql/conf.d/mysql-custom.cnf
      - ./schema/viconsaga.sql:/docker-entrypoint-initdb.d/viconsaga.sql
    ports: ["3307:3306"]
    networks: [viconsaga-network]

networks:
  viconsaga-network:
    driver: bridge

volumes:
  db_data:

Deploy em VPS Oracle Cloud

Armadilhas encontradas

1. Docker rootless vs Docker tradicional

Diagnóstico: sudo ss -tlnp | grep 8180 — se aparecer rootlesskit, está no Docker errado. Solução: mover para /opt/ e usar sudo docker compose.

2. Oracle Cloud só roteia 80 e 443 externamente

Mesmo com Security List liberando outras portas, tráfego externo só flui por 80/443. Todos os serviços devem ser publicados via NPM.

3. Redes Docker isoladas (NPM não enxerga Vigiante)

Solução: adicionar rede do NPM como externa no docker-compose.yml:

services:
  app:
    networks:
      - viconsaga-network
      - npm-ruiogawa_default
  db:
    networks: [viconsaga-network]

networks:
  viconsaga-network:
    driver: bridge
  npm-ruiogawa_default:
    external: true

4. Filebrowser envia arquivos como root:root

sudo chown ubuntu:ubuntu ~/viconsaga_vigiante.zip

Configuração do Proxy Host no NPM

Campo Valor
Domain Names vigiante.com, www.vigiante.com
Scheme http
Forward Hostname/IP vigiante-app-1 (nome do container)
Forward Port 80 (Apache interno, NÃO 8180)
Block Common Exploits Ligado
Websockets Support Ligado

Script de deploy automatizado

Arquivo: deploy-vigiante.sh (disponível junto com o zip).

Configuração inicial (única vez)

sudo chown ubuntu:ubuntu ~/deploy-vigiante.sh
chmod +x ~/deploy-vigiante.sh

Uso

# Só código mudou (maioria dos casos):
sudo bash ~/deploy-vigiante.sh

# Dockerfile ou pasta docker/ mudou:
sudo bash ~/deploy-vigiante.sh --build

O que o script faz automaticamente

  1. Verifica que o zip existe em /home/ubuntu/viconsaga_vigiante.zip
  2. Backup do banco em /home/ubuntu/vigiante-backups/db-TIMESTAMP.sql
  3. Salva docker-compose.yml e connection.inc com timestamp
  4. Para containers (down sem -v — banco preservado)
  5. Ajusta permissão do zip (chown ubuntu:ubuntu)
  6. Extrai e substitui o código
  7. Restaura docker-compose.yml e connection.inc
  8. Sobe containers (com --build se solicitado)
  9. Verifica containers, redes e resposta HTTP
  10. Mantém últimos 10 backups, remove os mais antigos

O que o script preserva

  • Banco de dados: nunca apagado
  • docker-compose.yml: restaurado com redes do NPM
  • connection.inc: restaurado com chave Google Maps e credenciais

Questões pendentes e notas de manutenção

Dependem de configuração externa

  • SMTP: "Esqueci minha senha" só funciona com notifierSMTPHost, notifierEmail e notifierEmailPassword configurados em connection.inc
  • Maps Static API: necessária para miniaturas no relatório HTML — habilitar no Google Cloud Console

Dívida técnica

  • APIs Google Maps legacy (Marker, SearchBox) — monitorar deprecation
  • Bug only_full_group_by — pode existir em outras queries além das 4 corrigidas
  • Encoding ISO-8859-1 — sistema legado; migração para UTF-8 seria refactor grande

Credenciais sensíveis

Item Arquivo Observação
Chave Google Maps connection.inc Restringir por domínio
Senha DB viconsaga connection.inc + docker-compose.yml Trocar em produção
MYSQL_ROOT_PASSWORD docker-compose.yml Trocar em produção
SMTP (futuro) connection.inc Gmail App Password recomendado

Backups

# Banco
sudo docker compose exec db mysqldump --no-tablespaces \
    -u viconsaga -psecret viconsaga > backup.sql

# Uploads (fotos, anexos)
sudo tar czf uploads.tgz /opt/vigiante/{projects,tmp,sensors,support,archives}

Referências rápidas

Arquivos com alterações substanciais

  • ajax.php — conversor UTF-8→ISO-8859-1, handler QR (HQRC), handler CSV (RCSV/RCSVM)
  • output.php — fix de headers e buffer
  • engine/strings.php — arrGetImagePallete (SVG), detecção X-Forwarded-Proto
  • engine/formrecord.php — 3 bugs GROUP BY + filtro de data
  • engine/user.php — selectUserProjects GROUP BY
  • engine/report.php — generateReportCSV, reportCSVMerge, encoding UTF-8
  • engine/importer.php — arrExtractCSVShapeObject (vírgula decimal, XLSX)
  • engine/project.php — insertProject com senha custom
  • engine/form.php — strShareURL
  • site/join.php, site/projects.php, site/sobre.php, site/home.php
  • index.php — modal QR, roteamento, footer
  • qr.php — página pública QR (arquivo novo)
  • js/main.js — função qrCode, qrCodeDownload
  • .htaccess — rota /qr/N
  • css/main.css — fontes
  • Dockerfile — extensões zip, gd, permissões
  • docker-compose.yml, docker/apache-vhost.conf
  • deploy-vigiante.sh — script de deploy (arquivo novo)

Comandos úteis

# Logs Apache em tempo real
sudo docker compose logs -f app

# MySQL CLI com encoding correto
sudo docker compose exec db mysql --default-character-set=latin1 \
    -u viconsaga -psecret viconsaga

# Ver redes do container
sudo docker inspect vigiante-app-1 | grep -A2 Networks

# Verificar resposta HTTP
curl -I http://localhost:8180

# Logs de erro recentes
sudo docker compose logs --tail=50 app 2>&1 | grep -iE "error|warning|fatal"

Backup automatizado — Backblaze B2

Por que Backblaze B2

O Backblaze B2 foi escolhido em vez do Google Drive por duas razões principais: a Application Key não expira (diferente do OAuth do Drive que precisa de renovação periódica e browser para reautenticar), e o custo é muito menor para armazenamento crescente (10 GB gratuitos permanentes, depois US$ 0,006/GB/mês).

Estrutura no B2

vigiante-backups/
  db/
    db-YYYYMMDD-HHMM.sql.gz   ← dump completo compactado do banco
  files/
    current/                   ← espelho completo do /opt/vigiante (incremental)
    snapshots/YYYYMMDD-HHMM/  ← versões anteriores de arquivos alterados/deletados

Configuração inicial (única vez)

1. Criar conta e bucket no Backblaze: - Acessa https://www.backblaze.com → criar conta gratuita - B2 Cloud Storage → Buckets → Create a Bucket - Nome: vigiante-backups | Files: Private - Account → Application Keys → Add a New Application Key - Nome: vigiante-rclone | Bucket: vigiante-backups | Permissions: Read and Write - Anotar keyID e applicationKey — aparecem só uma vez

2. Instalar e configurar rclone na VPS:

curl https://rclone.org/install.sh | sudo bash

rclone config
# → n → Name: b2 → Storage: b2
# → account: SEU-keyID
# → key: SEU-applicationKey
# → demais: Enter

# Copiar config para root (necessário porque os scripts rodam com sudo)
sudo mkdir -p /root/.config/rclone
sudo cp ~/.config/rclone/rclone.conf /root/.config/rclone/rclone.conf

# Testar
sudo rclone lsd b2:vigiante-backups

3. Instalar os scripts:

# Fazer upload de backup-vigiante.sh e restore-vigiante.sh via Filebrowser
sudo chown ubuntu:ubuntu ~/backup-vigiante.sh ~/restore-vigiante.sh
chmod +x ~/backup-vigiante.sh ~/restore-vigiante.sh

# Teste manual
sudo bash ~/backup-vigiante.sh

4. Agendar no cron:

sudo crontab -e
# Adicionar:
0 3 * * * sudo bash /home/ubuntu/backup-vigiante.sh >> /var/log/vigiante-backup.log 2>&1

O que é incluído no backup

O script sincroniza todo /opt/vigiante — código PHP, connection.inc, docker-compose.yml, uploads, imagens de marcadores, etc.

Exclusões (regeneráveis ou temporários): - .git/ - engine/qrcode/cache/ - tmp/*.png e tmp/*.jpg - *.log

O banco de dados recebe tratamento separado via mysqldump compactado com gzip -9, garantindo consistência transacional (--single-transaction).

Comportamento incremental

Na primeira execução: envia tudo (~110 MB no caso do Vigiante/PICAPS). Nas execuções seguintes: envia apenas o que mudou (checksum SHA1). Arquivos deletados ou alterados no servidor são preservados em snapshots/TIMESTAMP/ em vez de serem removidos do B2.

Como restaurar

Usar o script interativo restore-vigiante.sh:

sudo bash ~/restore-vigiante.sh

Menu com 5 opções: 1. Banco de dados — lista os dumps disponíveis, escolhe um, restaura via mysql 2. Aplicação completa — sincroniza current/ de volta para /opt/vigiante 3. Arquivo específico de snapshot — restaura uma versão anterior de um arquivo 4. Tudo — banco + aplicação 5. Listar backups — mostra dumps, snapshots e uso total no B2

Restauração manual do banco:

rclone copy "b2:vigiante-backups/db/db-YYYYMMDD-HHMM.sql.gz" /tmp/
gunzip -c /tmp/db-YYYYMMDD-HHMM.sql.gz | \
    sudo docker compose -f /opt/vigiante/docker-compose.yml exec -T db \
    mysql -u viconsaga -psecret viconsaga

Observações

  • O script mantém por padrão 2 dumps do banco e 2 snapshots de arquivos. Ajustável via KEEP_DB_COPIES e KEEP_SNAPSHOTS no topo do backup-vigiante.sh.
  • Verificar o log periodicamente: tail -50 /var/log/vigiante-backup.log
  • Se o sudo rclone falhar com autenticação, re-copiar a config: sudo cp ~/.config/rclone/rclone.conf /root/.config/rclone/rclone.conf

Remoção do botão "Cadastre-se"

Arquivo: index.php

Quando o usuário não estava logado e acessava a página de um projeto, aparecia um botão verde "Cadastre-se" na navbar. Esse botão abria um modal de cadastro inline (userJoinShow()) que foi desativado pois o fluxo correto de cadastro de novos projetos é via https://vigiante.com/site/join.

Correção: removido o bloco condicional que renderizava o botão:

<!-- removido -->
<?php if (($arrProject['intAllowJoinAsLevel'] > 0) && ($_SESSION['idUser'] <= 0)) { ?>
<a class="btn btn-success" onClick="userJoinShow();">Cadastre-se</a>
<?php } ?>

O modal e as funções JS userJoinShow() / userJoinSubmit() foram mantidos no código por ora (não causam problema, apenas ficam inacessíveis pela interface).