From fa96ec4aea3cc3e7f73c4803308e1c1f5124c0e6 Mon Sep 17 00:00:00 2001 From: HalbeBruno Date: Thu, 12 Mar 2026 15:20:38 -0300 Subject: [PATCH] Implementacao upload anexos de ordens --- .dockerignore | 1 + .gitignore | 1 + app/Controllers/OrderController.php | 64 ++++++-- app/Models/OrderAttachment.php | 41 +++++ app/Services/AttachmentService.php | 151 ++++++++++++++++++ app/Services/OrderProcessor.php | 2 +- app/routes.php | 3 + .../2026_03_12_create_order_attachments.sql | 17 ++ database/schema.sql | 17 ++ resources/views/admin/orders/create.php | 32 ++++ resources/views/admin/orders/view.php | 34 ++++ 11 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 app/Models/OrderAttachment.php create mode 100644 app/Services/AttachmentService.php create mode 100644 database/migrations/2026_03_12_create_order_attachments.sql diff --git a/.dockerignore b/.dockerignore index e7ca137..a0137ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,4 @@ database/ .vscode/ vendor/ node_modules/ +storage/ diff --git a/.gitignore b/.gitignore index 8ad0243..3bb36c2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ agent/dist/* .idea .DS_Store mysql_data/ +storage/ diff --git a/app/Controllers/OrderController.php b/app/Controllers/OrderController.php index d6df478..61dac0b 100644 --- a/app/Controllers/OrderController.php +++ b/app/Controllers/OrderController.php @@ -3,6 +3,8 @@ namespace App\Controllers; use App\Models\Order; +use App\Models\OrderAttachment; +use App\Services\AttachmentService; use App\Services\OrderProcessor; use App\Services\TelegramService; use App\Utils\View; @@ -88,26 +90,32 @@ class OrderController $sql = "INSERT INTO orders (title, type, content, received_at) VALUES (:title, :type, :content, :received_at)"; $stmt = $conn->prepare($sql); $stmt->execute([ - 'title' => $title, - 'type' => $type, - 'content' => $content, + 'title' => $title, + 'type' => $type, + 'content' => $content, 'received_at' => $received_at ]); $orderId = $conn->lastInsertId(); - // Process CSV + // Process CSV (unchanged) $processor = new OrderProcessor(); $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 $telegramService = new TelegramService(); $typeLabel = ($type === 'block') ? 'Bloqueio' : (($type === 'unblock') ? 'Desbloqueio' : $type); $telegramService->sendOrderNotification([ - 'id' => $orderId, + 'id' => $orderId, 'title' => $title, - 'type' => $typeLabel + 'type' => $typeLabel ], $count); $_SESSION['flash_success'] = "Ordem criada com sucesso! $count domínios processados."; @@ -134,11 +142,47 @@ class OrderController $stmt->execute(['id' => $id]); $items = $stmt->fetchAll(); + // Get Attachments + $attachmentModel = new OrderAttachment(); + $attachments = $attachmentModel->findByOrderId((int) $id); + View::render('layouts.admin', [ - 'title' => 'Detalhes da Ordem #' . $id, - 'content' => __DIR__ . '/../../resources/views/admin/orders/view.php', - 'order' => $order, - 'items' => $items + 'title' => 'Detalhes da Ordem #' . $id, + 'content' => __DIR__ . '/../../resources/views/admin/orders/view.php', + 'order' => $order, + '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; + } } diff --git a/app/Models/OrderAttachment.php b/app/Models/OrderAttachment.php new file mode 100644 index 0000000..73712fb --- /dev/null +++ b/app/Models/OrderAttachment.php @@ -0,0 +1,41 @@ +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(); + } +} diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php new file mode 100644 index 0000000..febc364 --- /dev/null +++ b/app/Services/AttachmentService.php @@ -0,0 +1,151 @@ + 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', + ]; + + /** Tamanho máximo por arquivo: 20 MB */ + private const MAX_SIZE = 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']}"); + } + + if ($file['size'] > self::MAX_SIZE) { + throw new \Exception("O arquivo '{$file['name']}' excede o tamanho máximo permitido de 20 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; + } +} diff --git a/app/Services/OrderProcessor.php b/app/Services/OrderProcessor.php index c6268ab..5283d24 100644 --- a/app/Services/OrderProcessor.php +++ b/app/Services/OrderProcessor.php @@ -22,7 +22,7 @@ class OrderProcessor $domains = []; while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) { - $raw = trim($data[0]); + $raw = trim($data[0] ?? ''); if (empty($raw)) continue; diff --git a/app/routes.php b/app/routes.php index d5034af..72d7e70 100644 --- a/app/routes.php +++ b/app/routes.php @@ -84,6 +84,9 @@ $router->addMiddleware(\App\Middleware\AdminMiddleware::class); $router->get('/admin/orders/view/{id}', [\App\Controllers\OrderController::class, 'view']); $router->addMiddleware(\App\Middleware\AdminMiddleware::class); +$router->get('/admin/orders/attachments/{id}/download', [\App\Controllers\OrderController::class, 'downloadAttachment']); +$router->addMiddleware(\App\Middleware\AdminMiddleware::class); + // Settings $router->get('/admin/settings', [\App\Controllers\SettingsController::class, 'index']); $router->addMiddleware(\App\Middleware\AdminMiddleware::class); diff --git a/database/migrations/2026_03_12_create_order_attachments.sql b/database/migrations/2026_03_12_create_order_attachments.sql new file mode 100644 index 0000000..a23188a --- /dev/null +++ b/database/migrations/2026_03_12_create_order_attachments.sql @@ -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; diff --git a/database/schema.sql b/database/schema.sql index ab11843..f9c89b1 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -143,4 +143,21 @@ CREATE TABLE `settings` ( PRIMARY KEY (`key`) ) 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; + diff --git a/resources/views/admin/orders/create.php b/resources/views/admin/orders/create.php index b68baf6..0aadd29 100644 --- a/resources/views/admin/orders/create.php +++ b/resources/views/admin/orders/create.php @@ -68,6 +68,38 @@ + +
+ +

Arquivos da ordem judicial (decisão, ofícios, etc.). Tipos aceitos: PDF, PNG, JPG, GIF, DOC, DOCX, XLS, XLSX, TXT — máx. 20 MB por arquivo.

+
+ +
+
diff --git a/resources/views/admin/orders/view.php b/resources/views/admin/orders/view.php index d628c5c..4606c5d 100644 --- a/resources/views/admin/orders/view.php +++ b/resources/views/admin/orders/view.php @@ -29,6 +29,40 @@
+ +
+
+ + + +

Anexos ()

+
+ +
+ +

Domínios Afetados ()