Compare commits
11 Commits
6a1312d55c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1356302060 | ||
|
|
d709d4cb09 | ||
|
|
b4799a1d42 | ||
|
|
6142f0cc65 | ||
|
|
64f8524cd1 | ||
|
|
1434523bab | ||
|
|
af9ce9a8fb | ||
|
|
cb09163d7d | ||
|
|
9a2536932f | ||
|
|
a58805a7dc | ||
|
|
fa96ec4aea |
@@ -1,14 +1,4 @@
|
|||||||
|
mysql_data
|
||||||
|
node_modules
|
||||||
|
agent
|
||||||
.git
|
.git
|
||||||
.gitignore
|
|
||||||
.env
|
|
||||||
.env.example
|
|
||||||
docker-compose.yml
|
|
||||||
Dockerfile
|
|
||||||
README.md
|
|
||||||
DEPLOY.md
|
|
||||||
agent/
|
|
||||||
database/
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
vendor/
|
|
||||||
node_modules/
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ agent/dist/*
|
|||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
mysql_data/
|
mysql_data/
|
||||||
|
storage/
|
||||||
|
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
|||||||
# Install PHP extensions
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd curl
|
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd curl
|
||||||
|
|
||||||
|
# Configure PHP upload limits
|
||||||
|
RUN echo "upload_max_filesize = 40M\npost_max_size = 50M" > /usr/local/etc/php/conf.d/uploads.ini
|
||||||
|
|
||||||
# Enable Apache mod_rewrite
|
# Enable Apache mod_rewrite
|
||||||
RUN a2enmod rewrite
|
RUN a2enmod rewrite
|
||||||
|
|
||||||
|
|||||||
86
MIGRATION.md
86
MIGRATION.md
@@ -1,86 +0,0 @@
|
|||||||
# Guia de Migração para Produção
|
|
||||||
|
|
||||||
Este guia descreve o procedimento seguro para atualizar o ambiente de produção para a nova arquitetura de persistência local, garantindo que nenhum dado seja perdido.
|
|
||||||
|
|
||||||
## Pré-requisitos
|
|
||||||
|
|
||||||
1. Acesso SSH ao servidor de produção.
|
|
||||||
2. Permissões de `sudo` ou docker.
|
|
||||||
|
|
||||||
## Procedimento Passo-a-Passo
|
|
||||||
|
|
||||||
### 1. Preparação e Backup
|
|
||||||
|
|
||||||
Antes de atualizar qualquer código, faça o backup do banco de dados atual (que está rodando no volume Docker antigo).
|
|
||||||
|
|
||||||
Se você já baixou o novo `Makefile`, pode usar o comando abaixo. Caso contrário, copie o `Makefile` para o servidor ou execute o comando manual.
|
|
||||||
|
|
||||||
**Opção A: Usando Makefile (Recomendado)**
|
|
||||||
```bash
|
|
||||||
make backup
|
|
||||||
```
|
|
||||||
*Isso criará um arquivo `.sql` na pasta `backups/`.*
|
|
||||||
|
|
||||||
**Opção B: Manual**
|
|
||||||
```bash
|
|
||||||
mkdir -p backups
|
|
||||||
docker exec dnsblock-db mysqldump -u root -p<SUA_SENHA_ATUAL> --all-databases > backups/backup_pre_migration.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Atualização do Código
|
|
||||||
|
|
||||||
Baixe a versão mais recente do código que contém a nova configuração do `docker-compose.yml` (com volume `./mysql_data`) e o `.env` seguro.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull origin main
|
|
||||||
# Ou copie os arquivos manualmente se não usar git
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Configuração do Ambiente
|
|
||||||
|
|
||||||
1. Certifique-se de que o arquivo `.env` está configurado corretamente com as novas variáveis seguras (caso tenha mudado).
|
|
||||||
2. Garanta que o `.env` **NÃO** está versionado no git.
|
|
||||||
|
|
||||||
### 4. Reinicialização dos Serviços
|
|
||||||
|
|
||||||
Agora vamos trocar a infraestrutura. Ao rodar este comando, o container do banco será recriado apontando para a nova pasta `./mysql_data` (que estará vazia inicialmente).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make down
|
|
||||||
make up
|
|
||||||
```
|
|
||||||
*Ou `docker-compose down && docker-compose up -d`*
|
|
||||||
|
|
||||||
### 5. Restauração dos Dados
|
|
||||||
|
|
||||||
Agora que o banco novo está vazio, vamos importar o backup que fizemos no Passo 1.
|
|
||||||
|
|
||||||
Identifique o nome do arquivo gerado em `backups/` (ex: `backups/backup_20241217_100000.sql`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make restore FILE=backups/backup_YYYYMMDD_HHMMSS.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual:**
|
|
||||||
```bash
|
|
||||||
docker exec -i dnsblock-db mysql -u root -p<NOVA_SENHA_ENV> < backups/backup_pre_migration.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Verificação
|
|
||||||
|
|
||||||
Verifique se os dados foram restaurados corretamente acessando a aplicação ou consultando o banco:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verifica se o cliente "IPv0 Soluções" está correto
|
|
||||||
docker exec -i dnsblock-db mysql -u root -p<NOVA_SENHA_ENV> dnsblock -e "SELECT name FROM clients WHERE id=1;"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Limpeza (Opcional)
|
|
||||||
|
|
||||||
Após confirmar que tudo está funcionando, você pode listar e remover o volume antigo do Docker, se desejar liberar espaço.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker volume ls
|
|
||||||
# Remova o volume 'dnsblock_dbdata' se ele existir e não for mais usado
|
|
||||||
# docker volume rm dnsblock_dbdata
|
|
||||||
```
|
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderAttachment;
|
||||||
|
use App\Services\AttachmentService;
|
||||||
use App\Services\OrderProcessor;
|
use App\Services\OrderProcessor;
|
||||||
use App\Services\TelegramService;
|
use App\Services\TelegramService;
|
||||||
use App\Utils\View;
|
use App\Utils\View;
|
||||||
@@ -70,12 +72,43 @@ class OrderController
|
|||||||
|
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
|
// Detect post_max_size overflow
|
||||||
$_SESSION['flash_error'] = "Erro no upload do arquivo CSV.";
|
if (empty($_POST) && empty($_FILES) && isset($_SERVER['CONTENT_LENGTH']) && (int)$_SERVER['CONTENT_LENGTH'] > 0) {
|
||||||
|
$_SESSION['flash_error'] = "O tamanho total dos arquivos enviados excede o limite permitido pelo servidor.";
|
||||||
View::redirect('/admin/orders/create');
|
View::redirect('/admin/orders/create');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isset($_FILES['csv_file']) || $_FILES['csv_file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$csvError = $_FILES['csv_file']['error'] ?? 'desconhecido';
|
||||||
|
if ($csvError === UPLOAD_ERR_INI_SIZE || $csvError === UPLOAD_ERR_FORM_SIZE) {
|
||||||
|
$_SESSION['flash_error'] = "O arquivo CSV/TXT excede o limite de tamanho permitido pelo servidor.";
|
||||||
|
} else {
|
||||||
|
$_SESSION['flash_error'] = "Erro no upload do arquivo CSV. (código " . $csvError . ")";
|
||||||
|
}
|
||||||
|
View::redirect('/admin/orders/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate attachments before inserting the order
|
||||||
|
if (isset($_FILES['attachments']) && !empty($_FILES['attachments']['name'][0])) {
|
||||||
|
$attachments = $_FILES['attachments'];
|
||||||
|
$count = count($attachments['name']);
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$error = $attachments['error'][$i];
|
||||||
|
if ($error !== UPLOAD_ERR_OK && $error !== UPLOAD_ERR_NO_FILE) {
|
||||||
|
$fileName = $attachments['name'][$i];
|
||||||
|
if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
|
||||||
|
$_SESSION['flash_error'] = "O anexo '{$fileName}' excede o tamanho máximo permitido.";
|
||||||
|
} else {
|
||||||
|
$_SESSION['flash_error'] = "Erro no upload do anexo '{$fileName}': código {$error}.";
|
||||||
|
}
|
||||||
|
View::redirect('/admin/orders/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$title = $_POST['title'];
|
$title = $_POST['title'];
|
||||||
$type = $_POST['type'];
|
$type = $_POST['type'];
|
||||||
$content = \App\Utils\TextFormatter::normalizeLineBreaks($_POST['content']);
|
$content = \App\Utils\TextFormatter::normalizeLineBreaks($_POST['content']);
|
||||||
@@ -83,6 +116,7 @@ class OrderController
|
|||||||
|
|
||||||
$conn = \App\Config\Database::getInstance()->getConnection();
|
$conn = \App\Config\Database::getInstance()->getConnection();
|
||||||
|
|
||||||
|
$conn->beginTransaction();
|
||||||
try {
|
try {
|
||||||
// Create Order
|
// Create Order
|
||||||
$sql = "INSERT INTO orders (title, type, content, received_at) VALUES (:title, :type, :content, :received_at)";
|
$sql = "INSERT INTO orders (title, type, content, received_at) VALUES (:title, :type, :content, :received_at)";
|
||||||
@@ -96,10 +130,16 @@ class OrderController
|
|||||||
|
|
||||||
$orderId = $conn->lastInsertId();
|
$orderId = $conn->lastInsertId();
|
||||||
|
|
||||||
// Process CSV
|
// Process CSV (unchanged)
|
||||||
$processor = new OrderProcessor();
|
$processor = new OrderProcessor();
|
||||||
$count = $processor->process($orderId, $type, $_FILES['csv_file']['tmp_name']);
|
$count = $processor->process($orderId, $type, $_FILES['csv_file']['tmp_name']);
|
||||||
|
|
||||||
|
// Store attachments (if any were sent)
|
||||||
|
if (isset($_FILES['attachments']) && !empty($_FILES['attachments']['name'][0])) {
|
||||||
|
$attachmentService = new AttachmentService();
|
||||||
|
$attachmentService->storeFiles((int) $orderId, $_FILES['attachments']);
|
||||||
|
}
|
||||||
|
|
||||||
// Send Telegram Notification
|
// Send Telegram Notification
|
||||||
$telegramService = new TelegramService();
|
$telegramService = new TelegramService();
|
||||||
$typeLabel = ($type === 'block') ? 'Bloqueio' : (($type === 'unblock') ? 'Desbloqueio' : $type);
|
$typeLabel = ($type === 'block') ? 'Bloqueio' : (($type === 'unblock') ? 'Desbloqueio' : $type);
|
||||||
@@ -110,10 +150,13 @@ class OrderController
|
|||||||
'type' => $typeLabel
|
'type' => $typeLabel
|
||||||
], $count);
|
], $count);
|
||||||
|
|
||||||
|
$conn->commit();
|
||||||
|
|
||||||
$_SESSION['flash_success'] = "Ordem criada com sucesso! $count domínios processados.";
|
$_SESSION['flash_success'] = "Ordem criada com sucesso! $count domínios processados.";
|
||||||
View::redirect('/admin/orders');
|
View::redirect('/admin/orders');
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
$conn->rollBack();
|
||||||
$_SESSION['flash_error'] = "Erro ao processar ordem: " . $e->getMessage();
|
$_SESSION['flash_error'] = "Erro ao processar ordem: " . $e->getMessage();
|
||||||
View::redirect('/admin/orders/create');
|
View::redirect('/admin/orders/create');
|
||||||
}
|
}
|
||||||
@@ -134,11 +177,79 @@ class OrderController
|
|||||||
$stmt->execute(['id' => $id]);
|
$stmt->execute(['id' => $id]);
|
||||||
$items = $stmt->fetchAll();
|
$items = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get Attachments
|
||||||
|
$attachmentModel = new OrderAttachment();
|
||||||
|
$attachments = $attachmentModel->findByOrderId((int) $id);
|
||||||
|
|
||||||
View::render('layouts.admin', [
|
View::render('layouts.admin', [
|
||||||
'title' => 'Detalhes da Ordem #' . $id,
|
'title' => 'Detalhes da Ordem #' . $id,
|
||||||
'content' => __DIR__ . '/../../resources/views/admin/orders/view.php',
|
'content' => __DIR__ . '/../../resources/views/admin/orders/view.php',
|
||||||
'order' => $order,
|
'order' => $order,
|
||||||
'items' => $items
|
'items' => $items,
|
||||||
|
'attachments' => $attachments,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function downloadAttachment($id)
|
||||||
|
{
|
||||||
|
$attachmentModel = new OrderAttachment();
|
||||||
|
$attachment = $attachmentModel->find($id);
|
||||||
|
|
||||||
|
if (!$attachment) {
|
||||||
|
$_SESSION['flash_error'] = "Anexo não encontrado.";
|
||||||
|
View::redirect('/admin/orders');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentService = new AttachmentService();
|
||||||
|
$filePath = $attachmentService->getFilePath((int) $attachment['order_id'], $attachment['stored_name']);
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
$_SESSION['flash_error'] = "Arquivo físico não encontrado no servidor.";
|
||||||
|
View::redirect('/admin/orders/view/' . $attachment['order_id']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve o arquivo para download
|
||||||
|
header('Content-Type: ' . ($attachment['mime_type'] ?? 'application/octet-stream'));
|
||||||
|
header('Content-Disposition: attachment; filename="' . addslashes($attachment['original_name']) . '"');
|
||||||
|
header('Content-Length: ' . filesize($filePath));
|
||||||
|
header('Cache-Control: private, no-cache');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
|
||||||
|
readfile($filePath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadAttachments($id)
|
||||||
|
{
|
||||||
|
$orderModel = new \App\Models\Order();
|
||||||
|
$order = $orderModel->find($id);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
$_SESSION['flash_error'] = "Ordem não encontrada.";
|
||||||
|
View::redirect('/admin/orders');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isset($_FILES['new_attachments']) && !empty($_FILES['new_attachments']['name'][0])) {
|
||||||
|
$attachments = $_FILES['new_attachments'];
|
||||||
|
$attachmentService = new AttachmentService();
|
||||||
|
$savedCount = $attachmentService->storeFiles((int) $id, $attachments);
|
||||||
|
|
||||||
|
if ($savedCount > 0) {
|
||||||
|
$_SESSION['flash_success'] = "{$savedCount} anexo(s) adicionado(s) com sucesso à ordem!";
|
||||||
|
} else {
|
||||||
|
$_SESSION['flash_error'] = "Nenhum arquivo válido foi recebido.";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$_SESSION['flash_error'] = "Nenhum arquivo foi selecionado.";
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['flash_error'] = "Erro de upload: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect('/admin/orders/view/' . $id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Models/OrderAttachment.php
Normal file
41
app/Models/OrderAttachment.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Model;
|
||||||
|
|
||||||
|
class OrderAttachment extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'order_attachments';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna todos os anexos de uma ordem específica.
|
||||||
|
*/
|
||||||
|
public function findByOrderId(int $orderId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT * FROM {$this->table} WHERE order_id = :order_id ORDER BY created_at ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute(['order_id' => $orderId]);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insere um registro de anexo no banco.
|
||||||
|
*/
|
||||||
|
public function create(array $data): int
|
||||||
|
{
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"INSERT INTO {$this->table} (order_id, original_name, stored_name, mime_type, size)
|
||||||
|
VALUES (:order_id, :original_name, :stored_name, :mime_type, :size)"
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'order_id' => $data['order_id'],
|
||||||
|
'original_name' => $data['original_name'],
|
||||||
|
'stored_name' => $data['stored_name'],
|
||||||
|
'mime_type' => $data['mime_type'] ?? null,
|
||||||
|
'size' => $data['size'] ?? null,
|
||||||
|
]);
|
||||||
|
return (int) $this->conn->lastInsertId();
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/Services/AttachmentService.php
Normal file
168
app/Services/AttachmentService.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\OrderAttachment;
|
||||||
|
|
||||||
|
class AttachmentService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Tipos de arquivo permitidos para anexo.
|
||||||
|
* Mapeamento mime_type => extensões aceitas.
|
||||||
|
*/
|
||||||
|
private const ALLOWED_TYPES = [
|
||||||
|
'application/pdf' => 'pdf',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'application/msword' => 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||||
|
'application/vnd.ms-excel' => 'xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||||
|
'text/plain' => 'txt',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna o tamanho máximo de upload, lendo do PHP ini.
|
||||||
|
*/
|
||||||
|
private function getMaxSize(): int
|
||||||
|
{
|
||||||
|
$val = ini_get('upload_max_filesize');
|
||||||
|
if (empty($val)) return 20 * 1024 * 1024;
|
||||||
|
$val = trim($val);
|
||||||
|
$last = strtolower($val[strlen($val)-1]);
|
||||||
|
$val = (int)$val;
|
||||||
|
switch($last) {
|
||||||
|
case 'g': $val *= 1024;
|
||||||
|
case 'm': $val *= 1024;
|
||||||
|
case 'k': $val *= 1024;
|
||||||
|
}
|
||||||
|
return $val > 0 ? $val : 20 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raiz do diretório de uploads (fora do public/).
|
||||||
|
*/
|
||||||
|
private function getBaseUploadDir(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__, 2) . '/storage/uploads/orders';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pega o caminho do diretório de upload de uma ordem específica.
|
||||||
|
*/
|
||||||
|
private function getOrderUploadDir(int $orderId): string
|
||||||
|
{
|
||||||
|
return $this->getBaseUploadDir() . '/' . $orderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garante que o diretório de upload da ordem exista.
|
||||||
|
*/
|
||||||
|
private function ensureDirectory(string $path): void
|
||||||
|
{
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
mkdir($path, 0775, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processa e armazena os arquivos do campo de upload.
|
||||||
|
* Retorna a quantidade de arquivos salvos com sucesso.
|
||||||
|
*
|
||||||
|
* @param int $orderId
|
||||||
|
* @param array $filesInput Entry do $_FILES (já normalizado como array de arquivos)
|
||||||
|
* @return int
|
||||||
|
* @throws \Exception em caso de erro crítico
|
||||||
|
*/
|
||||||
|
public function storeFiles(int $orderId, array $filesInput): int
|
||||||
|
{
|
||||||
|
$uploadDir = $this->getOrderUploadDir($orderId);
|
||||||
|
$this->ensureDirectory($uploadDir);
|
||||||
|
|
||||||
|
$model = new OrderAttachment();
|
||||||
|
$saved = 0;
|
||||||
|
|
||||||
|
// Normaliza o $_FILES para iterar arquivo a arquivo
|
||||||
|
$files = $this->normalizeFilesArray($filesInput);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
// Ignora entradas vazias (campo não preenchido)
|
||||||
|
if ($file['error'] === UPLOAD_ERR_NO_FILE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
throw new \Exception("Erro no upload do arquivo '{$file['name']}': código {$file['error']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxSize = $this->getMaxSize();
|
||||||
|
if ($file['size'] > $maxSize) {
|
||||||
|
$maxSizeMB = floor($maxSize / (1024 * 1024));
|
||||||
|
throw new \Exception("O arquivo '{$file['name']}' excede o tamanho máximo permitido de {$maxSizeMB} MB.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detecta o tipo MIME real do arquivo (não confiar só no header HTTP)
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = $finfo->file($file['tmp_name']);
|
||||||
|
|
||||||
|
if (!array_key_exists($mimeType, self::ALLOWED_TYPES)) {
|
||||||
|
throw new \Exception("Tipo de arquivo não permitido: '{$file['name']}' ({$mimeType}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = self::ALLOWED_TYPES[$mimeType];
|
||||||
|
$storedName = uniqid('attach_', true) . '.' . $extension;
|
||||||
|
$destination = $uploadDir . '/' . $storedName;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $destination)) {
|
||||||
|
throw new \Exception("Falha ao mover o arquivo '{$file['name']}' para o servidor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->create([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'original_name' => $file['name'],
|
||||||
|
'stored_name' => $storedName,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'size' => $file['size'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$saved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna o caminho absoluto do arquivo no disco.
|
||||||
|
*/
|
||||||
|
public function getFilePath(int $orderId, string $storedName): string
|
||||||
|
{
|
||||||
|
return $this->getOrderUploadDir($orderId) . '/' . $storedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza o array $_FILES para uma lista linear de arquivos,
|
||||||
|
* independente de ser upload único ou múltiplo (name[]).
|
||||||
|
*/
|
||||||
|
private function normalizeFilesArray(array $files): array
|
||||||
|
{
|
||||||
|
// Se é um único arquivo, encapsula em array
|
||||||
|
if (!is_array($files['name'])) {
|
||||||
|
return [$files];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
$count = count($files['name']);
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$normalized[] = [
|
||||||
|
'name' => $files['name'][$i],
|
||||||
|
'type' => $files['type'][$i],
|
||||||
|
'tmp_name' => $files['tmp_name'][$i],
|
||||||
|
'error' => $files['error'][$i],
|
||||||
|
'size' => $files['size'][$i],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ class OrderProcessor
|
|||||||
|
|
||||||
$domains = [];
|
$domains = [];
|
||||||
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
|
while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
|
||||||
$raw = trim($data[0]);
|
$raw = trim($data[0] ?? '');
|
||||||
if (empty($raw))
|
if (empty($raw))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -36,7 +36,10 @@ class OrderProcessor
|
|||||||
|
|
||||||
$domains = array_unique($domains); // Remove duplicates in batch
|
$domains = array_unique($domains); // Remove duplicates in batch
|
||||||
|
|
||||||
|
$inTransaction = $conn->inTransaction();
|
||||||
|
if (!$inTransaction) {
|
||||||
$conn->beginTransaction();
|
$conn->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmtCheck = $conn->prepare("SELECT id FROM domains WHERE name = :name");
|
$stmtCheck = $conn->prepare("SELECT id FROM domains WHERE name = :name");
|
||||||
@@ -77,11 +80,15 @@ class OrderProcessor
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$inTransaction) {
|
||||||
$conn->commit();
|
$conn->commit();
|
||||||
|
}
|
||||||
return count($domains);
|
return count($domains);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
if (!$inTransaction) {
|
||||||
$conn->rollBack();
|
$conn->rollBack();
|
||||||
|
}
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ $router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
|||||||
$router->get('/admin/orders/view/{id}', [\App\Controllers\OrderController::class, 'view']);
|
$router->get('/admin/orders/view/{id}', [\App\Controllers\OrderController::class, 'view']);
|
||||||
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||||
|
|
||||||
|
$router->get('/admin/orders/attachments/{id}/download', [\App\Controllers\OrderController::class, 'downloadAttachment']);
|
||||||
|
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||||
|
|
||||||
|
$router->post('/admin/orders/attachments/upload/{id}', [\App\Controllers\OrderController::class, 'uploadAttachments']);
|
||||||
|
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
$router->get('/admin/settings', [\App\Controllers\SettingsController::class, 'index']);
|
$router->get('/admin/settings', [\App\Controllers\SettingsController::class, 'index']);
|
||||||
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
$router->addMiddleware(\App\Middleware\AdminMiddleware::class);
|
||||||
|
|||||||
17
database/migrations/2026_03_12_create_order_attachments.sql
Normal file
17
database/migrations/2026_03_12_create_order_attachments.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration: Criação da tabela order_attachments
|
||||||
|
-- Data: 2026-03-12
|
||||||
|
-- Descrição: Armazena metadados de arquivos anexados às ordens judiciais.
|
||||||
|
-- Os arquivos físicos ficam em storage/uploads/orders/{order_id}/
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `order_attachments` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`order_id` int(11) NOT NULL,
|
||||||
|
`original_name` varchar(255) NOT NULL COMMENT 'Nome original do arquivo enviado pelo usuário',
|
||||||
|
`stored_name` varchar(255) NOT NULL COMMENT 'Nome gerado para armazenamento no disco',
|
||||||
|
`mime_type` varchar(100) DEFAULT NULL,
|
||||||
|
`size` int(11) DEFAULT NULL COMMENT 'Tamanho em bytes',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_attachments_order` (`order_id`),
|
||||||
|
CONSTRAINT `fk_attachments_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -143,4 +143,21 @@ CREATE TABLE `settings` (
|
|||||||
PRIMARY KEY (`key`)
|
PRIMARY KEY (`key`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Tabela `order_attachments`
|
||||||
|
--
|
||||||
|
CREATE TABLE IF NOT EXISTS `order_attachments` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`order_id` int(11) NOT NULL,
|
||||||
|
`original_name` varchar(255) NOT NULL COMMENT 'Nome original do arquivo enviado pelo usuário',
|
||||||
|
`stored_name` varchar(255) NOT NULL COMMENT 'Nome gerado para armazenamento no disco',
|
||||||
|
`mime_type` varchar(100) DEFAULT NULL,
|
||||||
|
`size` int(11) DEFAULT NULL COMMENT 'Tamanho em bytes',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_attachments_order` (`order_id`),
|
||||||
|
CONSTRAINT `fk_attachments_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
||||||
|
|||||||
84
doc/ATUALIZACAO.md
Normal file
84
doc/ATUALIZACAO.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Procedimento de Atualização
|
||||||
|
|
||||||
|
Este documento descreve os passos necessários para atualizar a aplicação DNSBlock para a nova versão, incluindo a aplicação de migrações no banco de dados.
|
||||||
|
|
||||||
|
## 1. Backup do Banco de Dados (Recomendado)
|
||||||
|
|
||||||
|
Antes de qualquer atualização, é altamente recomendável fazer um backup do banco de dados atual para evitar perda de dados em caso de falhas.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make backup
|
||||||
|
# O backup será salvo na pasta backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Atualização do Código-Fonte
|
||||||
|
|
||||||
|
Baixe as atualizações mais recentes do repositório (se estiver usando git) ou substitua os arquivos da aplicação pela nova versão.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Reconstrução e Reinício dos Containers
|
||||||
|
|
||||||
|
Após atualizar o código, é fundamental reconstruir a imagem Docker da aplicação e reiniciar os containers. Isso garante que as novas configurações de ambiente (como aumento do limite de upload do PHP) sejam aplicadas corretamente.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
*(Nota: O comando `make restart` do Makefile apenas reinicia os containers, mas não força a reconstrução da imagem. Portanto, para atualizações de versão, utilize o comando acima).*
|
||||||
|
|
||||||
|
## 4. Aplicação das Migrações de Banco de Dados (SQL)
|
||||||
|
|
||||||
|
As atualizações que alteram a estrutura do banco de dados incluem arquivos `.sql` na pasta `database/migrations/`. Para aplicá-los, utilize o comando `docker exec` direcionando o arquivo para o container do MySQL.
|
||||||
|
|
||||||
|
Para aplicar a migração mais recente referente ao suporte a anexos nas ordens judiciais, execute na raiz do projeto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i dnsblock-db mysql -u root -p"$(grep DB_ROOT_PASSWORD .env | cut -d '=' -f2 | tr -d '\r')" dnsblock < database/migrations/2026_03_12_create_order_attachments.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra Geral para Migrações:**
|
||||||
|
Caso existam múltiplas migrações que ainda não foram aplicadas em seu ambiente de produção, aplique-as uma a uma, respeitando a ordem cronológica dos prefixos dos arquivos (ex: `2025_...`, `2026_...`). Exemplo genérico:
|
||||||
|
```bash
|
||||||
|
docker exec -i dnsblock-db mysql -u root -p"$(grep DB_ROOT_PASSWORD .env | cut -d '=' -f2 | tr -d '\r')" dnsblock < database/migrations/ARQUIVO_DE_MIGRACAO.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Permissões de Diretório (Armazenamento Persistente)
|
||||||
|
|
||||||
|
Para garantir que a aplicação consiga salvar arquivos enviados (como os PDFs de anexos) no diretório mapeado para o hospedeiro, é obrigatório ajustar as permissões do diretório `storage`.
|
||||||
|
|
||||||
|
Após os containers estarem em execução, rode o seguinte comando na raiz do projeto para conceder a posse e permissão correta ao usuário do container web (`www-data`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec dnsblock-app chown -R www-data:www-data /var/www/html/storage
|
||||||
|
docker exec dnsblock-app chmod -R 775 /var/www/html/storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Validação da Atualização
|
||||||
|
|
||||||
|
1. Acesse o sistema pelo navegador.
|
||||||
|
2. Limpe o cache do navegador (CTRL + F5) para garantir o carregamento de novos scripts, se houver.
|
||||||
|
3. Valide o funcionamento das novas entregas (ex: anexe um arquivo grande na página de Ordens Judiciais).
|
||||||
|
4. Verifique os logs se encontrar algum problema de comunicação:
|
||||||
|
```bash
|
||||||
|
make logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Limpeza e Otimização (Opcional)
|
||||||
|
|
||||||
|
Após a atualização bem-sucedida, é recomendado realizar uma limpeza no ambiente Docker para remover compilações antigas (imagens) e dados que não são mais utilizados, liberando espaço em disco no servidor.
|
||||||
|
|
||||||
|
**Atenção:** Os comandos abaixo não afetarão conteinêres em execução nem os volumes que estão associados a eles (seus dados estarão seguros).
|
||||||
|
|
||||||
|
Para remover **imagens antigas**, containers parados e redes não utilizadas:
|
||||||
|
```bash
|
||||||
|
docker system prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Se você mudou volumes recentemente (como na migração do banco de dados) e tem certeza de que os **volumes antigos não são mais necessários**:
|
||||||
|
```bash
|
||||||
|
docker volume prune -f
|
||||||
|
```
|
||||||
|
*(Nota: O comando de volumes remove TODOS os volumes Docker na máquina que não estejam atrelados a um container ativo. Use com cautela se houver outras aplicações no mesmo servidor).*
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
@@ -14,6 +12,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html
|
- ./:/var/www/html
|
||||||
|
- ./docker/php/custom.ini:/usr/local/etc/php/conf.d/custom.ini
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
3
docker/php/custom.ini
Normal file
3
docker/php/custom.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
upload_max_filesize = 40M
|
||||||
|
post_max_size = 50M
|
||||||
|
session.gc_maxlifetime = 14400
|
||||||
@@ -1,4 +1,68 @@
|
|||||||
<div class="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
<?php
|
||||||
|
$hasError = isset($_SESSION['flash_error']);
|
||||||
|
$errorMsg = $_SESSION['flash_error'] ?? '';
|
||||||
|
if ($hasError) {
|
||||||
|
unset($_SESSION['flash_error']); // Prevent toast from showing
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBytesFromIni($val) {
|
||||||
|
if (empty($val)) return 0;
|
||||||
|
$val = trim($val);
|
||||||
|
$last = strtolower($val[strlen($val)-1]);
|
||||||
|
$val = (int)$val;
|
||||||
|
switch($last) {
|
||||||
|
case 'g': $val *= 1024;
|
||||||
|
case 'm': $val *= 1024;
|
||||||
|
case 'k': $val *= 1024;
|
||||||
|
}
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxUploadSizeBytes = getBytesFromIni(ini_get('upload_max_filesize'));
|
||||||
|
$maxPostSizeBytes = getBytesFromIni(ini_get('post_max_size'));
|
||||||
|
|
||||||
|
// Fallbacks caso não consiga ler corretamente
|
||||||
|
if ($maxUploadSizeBytes <= 0) $maxUploadSizeBytes = 20 * 1024 * 1024;
|
||||||
|
if ($maxPostSizeBytes <= 0) $maxPostSizeBytes = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
$maxUploadSizeMB = floor($maxUploadSizeBytes / (1024 * 1024));
|
||||||
|
?>
|
||||||
|
<div class="max-w-3xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-6" x-data='orderForm(<?= $hasError ? 'true' : 'false' ?>, <?= htmlspecialchars(json_encode($errorMsg), ENT_QUOTES, 'UTF-8') ?>)'>
|
||||||
|
|
||||||
|
<!-- Modal de Erro -->
|
||||||
|
<div x-show="showModal" class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true" x-cloak>
|
||||||
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<!-- Background overlay -->
|
||||||
|
<div x-show="showModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" @click="showModal = false"></div>
|
||||||
|
|
||||||
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<!-- Painel do Modal -->
|
||||||
|
<div x-show="showModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full p-6">
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg leading-6 font-bold text-gray-900" id="modal-title" x-text="modalTitle"></h3>
|
||||||
|
<button @click="showModal = false" type="button" class="text-gray-400 hover:text-gray-500 focus:outline-none">
|
||||||
|
<span class="sr-only">Fechar</span>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<p class="text-sm text-gray-600" x-text="modalMessage"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button type="button" @click="showModal = false" class="inline-flex justify-center w-full rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-200 sm:w-auto sm:text-sm transition-colors">
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 mb-6">Nova Ordem Judicial</h3>
|
<h3 class="text-lg font-semibold text-gray-800 mb-6">Nova Ordem Judicial</h3>
|
||||||
|
|
||||||
|
|
||||||
@@ -6,7 +70,7 @@
|
|||||||
<!-- Quill Styles -->
|
<!-- Quill Styles -->
|
||||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||||
|
|
||||||
<form action="/admin/orders/store" method="POST" enctype="multipart/form-data" class="space-y-6" id="orderForm">
|
<form action="/admin/orders/store" method="POST" enctype="multipart/form-data" class="space-y-6" id="orderForm" @submit="validateForm($event)">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Título / Identificação da Ordem</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Título / Identificação da Ordem</label>
|
||||||
@@ -68,6 +132,38 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Anexos <span class="text-gray-400 font-normal">(PDF, imagens, documentos — opcional)</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">Arquivos da ordem judicial (decisão, ofícios, etc.). Tipos aceitos: PDF, PNG, JPG, GIF, DOC, DOCX, XLS, XLSX, TXT — máx. <?= $maxUploadSizeMB ?> MB por arquivo.</p>
|
||||||
|
<div x-data="{ attachNames: [] }" class="mt-1">
|
||||||
|
<label class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer hover:border-primary-500 transition-colors bg-white">
|
||||||
|
<div class="space-y-1 text-center w-full">
|
||||||
|
<svg class="mx-auto h-10 w-10 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex text-sm text-gray-600 justify-center">
|
||||||
|
<span class="font-medium text-primary-600 hover:text-primary-500">Selecionar arquivos</span>
|
||||||
|
<p class="pl-1">ou arraste e solte</p>
|
||||||
|
</div>
|
||||||
|
<template x-if="attachNames.length > 0">
|
||||||
|
<ul class="text-xs text-gray-700 mt-2 text-left list-disc list-inside">
|
||||||
|
<template x-for="name in attachNames" :key="name">
|
||||||
|
<li x-text="name"></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<p x-show="attachNames.length === 0" class="text-xs text-gray-400 mt-1">Nenhum arquivo selecionado</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" name="attachments[]" class="sr-only"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.xls,.xlsx,.txt"
|
||||||
|
multiple
|
||||||
|
@change="attachNames = Array.from($event.target.files).map(f => f.name)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-100">
|
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-100">
|
||||||
@@ -106,9 +202,47 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var form = document.getElementById('orderForm');
|
document.addEventListener('alpine:init', () => {
|
||||||
form.onsubmit = function () {
|
Alpine.data('orderForm', (initialShow, initialMsg) => ({
|
||||||
var content = document.querySelector('input[name=content]');
|
showModal: initialShow,
|
||||||
content.value = quill.root.innerHTML;
|
modalTitle: 'Aviso',
|
||||||
};
|
modalMessage: initialMsg || '',
|
||||||
|
|
||||||
|
validateForm(e) {
|
||||||
|
let totalSize = 0;
|
||||||
|
let maxSize = <?= $maxUploadSizeBytes ?>;
|
||||||
|
let maxPostSize = <?= $maxPostSizeBytes ?>;
|
||||||
|
|
||||||
|
let attachments = document.querySelector('input[name=\'attachments[]\']').files;
|
||||||
|
for(let i = 0; i < attachments.length; i++) {
|
||||||
|
if (attachments[i].size > maxSize) {
|
||||||
|
this.showError('Arquivo Muito Grande', 'O anexo \'' + attachments[i].name + '\' excede o limite permitido de <?= $maxUploadSizeMB ?> MB.');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
totalSize += attachments[i].size;
|
||||||
|
}
|
||||||
|
|
||||||
|
let csvFile = document.querySelector('input[name=\'csv_file\']').files[0];
|
||||||
|
if (csvFile) {
|
||||||
|
totalSize += csvFile.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize > maxPostSize) {
|
||||||
|
this.showError('Tamanho Total Excedido', 'O tamanho total de todos os arquivos excede o limite de submissão do servidor.');
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popula o campo oculto do Quill
|
||||||
|
document.querySelector('input[name=content]').value = window.quill.root.innerHTML;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
showError(title, message) {
|
||||||
|
this.modalTitle = title;
|
||||||
|
this.modalMessage = message;
|
||||||
|
this.showModal = true;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -29,6 +29,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 002.112 2.13" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800">Anexos (<?= count($attachments) ?>)</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($attachments)): ?>
|
||||||
|
<ul class="divide-y divide-gray-100">
|
||||||
|
<?php foreach ($attachments as $attachment): ?>
|
||||||
|
<li class="flex items-center justify-between px-6 py-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm text-gray-800 truncate"><?= htmlspecialchars($attachment['original_name']) ?></span>
|
||||||
|
<?php if ($attachment['size']): ?>
|
||||||
|
<span class="text-xs text-gray-400 flex-shrink-0"><?= number_format($attachment['size'] / 1024, 1) ?> KB</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/orders/attachments/<?= (int) $attachment['id'] ?>/download"
|
||||||
|
class="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-800 font-medium flex-shrink-0 ml-4"
|
||||||
|
title="Baixar <?= htmlspecialchars($attachment['original_name']) ?>">
|
||||||
|
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
Baixar
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="px-6 py-4 text-sm text-gray-500">
|
||||||
|
Nenhum anexo salvo para esta ordem.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100">
|
||||||
|
<form action="/admin/orders/attachments/upload/<?= (int) $order['id'] ?>" method="POST" enctype="multipart/form-data" class="flex flex-col sm:flex-row items-center gap-3">
|
||||||
|
<input type="file" name="new_attachments[]" multiple class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-gray-200 file:text-gray-700 hover:file:bg-gray-300 cursor-pointer" accept=".pdf,.png,.jpg,.jpeg,.gif,.doc,.docx,.xls,.xlsx,.txt" required>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-semibold hover:bg-primary-700 w-full sm:w-auto flex-shrink-0 transition-colors">
|
||||||
|
Adicionar Anexos
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50">
|
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||||
<h3 class="text-lg font-semibold text-gray-800">Domínios Afetados (<?= count($items) ?>)</h3>
|
<h3 class="text-lg font-semibold text-gray-800">Domínios Afetados (<?= count($items) ?>)</h3>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
type = $event.detail.type;
|
type = $event.detail.type;
|
||||||
title = $event.detail.title;
|
title = $event.detail.title;
|
||||||
message = $event.detail.message;
|
message = $event.detail.message;
|
||||||
setTimeout(() => show = false, 5000)
|
setTimeout(() => show = false, type === 'error' ? 15000 : 8000)
|
||||||
" x-init="
|
" x-init="
|
||||||
if (show) setTimeout(() => show = false, 5000);
|
if (show) setTimeout(() => show = false, type === 'error' ? 15000 : 8000);
|
||||||
window.notify = (type, title, message) => {
|
window.notify = (type, title, message) => {
|
||||||
$dispatch('notify', { type, title, message });
|
$dispatch('notify', { type, title, message });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user