diff --git a/.env.example b/.env.example index 79f56b5..7a9c212 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,5 @@ CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 -ACTA_IA_TOKEN=defina-um-token-interno - REDIS_HOST=localhost REDIS_PORT=6379 \ No newline at end of file diff --git a/README.md b/README.md index 1e1433f..19d0aea 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ O worker executa continuamente essa fila, recupera o job, envia o arquivo ao Clo O Redis armazena a fila e o estado necessário para o RQ controlar a execução. Se o processamento falhar, o job possui até três tentativas, com intervalos de 10, 30 e 60 segundos. O arquivo temporário é mantido durante as tentativas e removido quando o processamento termina com sucesso ou falha definitiva. -O Redis precisa estar acessível tanto pela API, que enfileira os jobs, quanto pelo worker, que os consome. Em ambientes com autenticação, configure também `REDIS_PASSWORD` no ambiente da aplicação e do worker. +O Redis precisa estar acessível tanto pela API, que enfileira os jobs, quanto pelo worker, que os consome. Em ambientes com autenticação, configure também `REDIS_PASSWORD`. A conexão usa TLS obrigatoriamente (`REDIS_SSL = True` em `app/config.py`), sem uma variável de ambiente para desativá-lo; use um serviço Redis com TLS habilitado. As configurações de conexão devem estar disponíveis para a API e para o worker. ## ✨ Funcionalidades @@ -82,12 +82,15 @@ CLOUDINARY_API_KEY=sua_api_key CLOUDINARY_API_SECRET=seu_api_secret ACTA_PG_API_URL=https://acta-pg-api.onrender.com/api/v1 -ACTA_IA_TOKEN=defina-um-token-interno REDIS_HOST=localhost REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD= ``` +`REDIS_DB` e `REDIS_PASSWORD` são opcionais conforme a configuração do serviço. A conexão Redis usa TLS obrigatoriamente; se `REDIS_HOST` for `localhost`, o Redis local também precisa estar configurado para TLS. + Nunca versione senhas, tokens ou credenciais do Cloudinary. O arquivo `.env.example` deve conter somente valores de referência. ## 🚀 Execução local @@ -173,12 +176,11 @@ Essa resposta não representa a confirmação final do Cloudinary. O status fina ```http POST /anexos/ia -Authorization: Bearer -X-Acta-Usuario-Id: +Authorization: Bearer Content-Type: multipart/form-data ``` -A IA usa uma credencial própria de serviço e informa apenas o usuário em nome de quem está realizando o upload. A API valida se esse usuário está ativo, obtém a empresa diretamente de `public.usuario_sistema` e verifica se o ciclo pertence à mesma empresa. O `id_empresa` e o `firebase_uid` não são recebidos nessa rota. +A rota `/ia` usa a mesma autenticação Bearer de `POST /anexos`: a API consulta o usuário na `acta-pg-api` e utiliza o contexto retornado para verificar a empresa do ciclo. Não há token de serviço ou cabeçalho de usuário separado para essa rota. Os campos do formulário e a resposta `202 Accepted` são os mesmos de `POST /anexos`. @@ -191,14 +193,15 @@ GET /anexos/{id_anexo} Authorization: Bearer ``` -Para a IA: +Para a rota de integração da IA: ```http GET /anexos/ia/{id_anexo} -Authorization: Bearer -X-Acta-Usuario-Id: +Authorization: Bearer ``` +Essa rota usa a mesma autenticação Bearer de `GET /anexos/{id_anexo}`. + A resposta informa o status atual, se o envio terminou com sucesso e a URL gerada pelo Cloudinary: ```json @@ -277,8 +280,6 @@ Os testes usam dublês para banco, Redis e Cloudinary. Portanto, a suíte valida └── worker.py # Inicialização do worker RQ ``` - - ## 🤝 Links e autoria - [Repositório](https://github.com/AppActa/acta-import-api) · [Licença MIT](LICENSE) · `acta.institutojef@gmail.com` diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..cdd591b --- /dev/null +++ b/app/config.py @@ -0,0 +1,19 @@ +from os import getenv + +from dotenv import load_dotenv + +load_dotenv() + +DB_URL = getenv('DB_URL') + +CLOUDINARY_CLOUD_NAME = getenv('CLOUDINARY_CLOUD_NAME') +CLOUDINARY_API_KEY = getenv('CLOUDINARY_API_KEY') +CLOUDINARY_API_SECRET = getenv('CLOUDINARY_API_SECRET') + +ACTA_PG_API_URL = getenv('ACTA_PG_API_URL') + +REDIS_HOST = getenv('REDIS_HOST') +REDIS_PORT = getenv('REDIS_PORT') +REDIS_DB = getenv('REDIS_DB') +REDIS_PASSWORD = getenv('REDIS_PASSWORD') +REDIS_SSL = True \ No newline at end of file diff --git a/app/database/postgres.py b/app/database/postgres.py index f88f4d2..8866a9e 100644 --- a/app/database/postgres.py +++ b/app/database/postgres.py @@ -1,12 +1,7 @@ from contextlib import contextmanager from fastapi import HTTPException -from dotenv import load_dotenv -from os import getenv from psycopg2 import connect - -load_dotenv() - -DB_URL = getenv('DB_URL') +from app.config import DB_URL def _conectar(): return connect(DB_URL) diff --git a/app/database/redis.py b/app/database/redis.py index 1fbfcdb..3410ef6 100644 --- a/app/database/redis.py +++ b/app/database/redis.py @@ -1,20 +1,15 @@ from fastapi import HTTPException -from redis import Redis, ConnectionPool +from redis import Redis, ConnectionPool, SSLConnection from redis.exceptions import RedisError -from dotenv import load_dotenv -from os import getenv from functools import lru_cache - -load_dotenv() - -REDIS_HOST = getenv('REDIS_HOST') -REDIS_PORT = getenv('REDIS_PORT') -REDIS_DB = getenv('REDIS_DB') -REDIS_PASSWORD = getenv('REDIS_PASSWORD') +from app.config import REDIS_DB, REDIS_HOST, REDIS_PASSWORD, REDIS_PORT, REDIS_SSL @lru_cache # apenas um pool por sessão def get_pool() -> ConnectionPool: - return ConnectionPool(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, password=REDIS_PASSWORD, max_connections=20, retry_on_timeout=True) + pool_options = {'host': REDIS_HOST, 'port': REDIS_PORT, 'db': REDIS_DB, 'password': REDIS_PASSWORD, 'max_connections': 20, 'retry_on_timeout': True} + if REDIS_SSL: + pool_options['connection_class'] = SSLConnection + return ConnectionPool(**pool_options) def get_conn() -> Redis: try: diff --git a/app/routes/anexos.py b/app/routes/anexos.py index 86b8405..de57fbc 100644 --- a/app/routes/anexos.py +++ b/app/routes/anexos.py @@ -3,7 +3,7 @@ from app.database.postgres import get_conn from app.schemas import AnexoForm, UsuarioAutenticado from app.services.anexos import consultar_anexo, processar_anexo -from app.services.autenticacao import obter_contexto_ia, obter_usuario_autenticado +from app.services.autenticacao import obter_usuario_autenticado router = APIRouter(prefix='/anexos', tags=['anexos']) @@ -12,13 +12,13 @@ async def post_anexo(arquivo: UploadFile = File(...), dados: AnexoForm = Depends return await processar_anexo(arquivo, dados, conn, usuario) @router.post('/ia', status_code=202) -async def post_anexo_ia(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_contexto_ia)): +async def post_anexo_ia(arquivo: UploadFile = File(...), dados: AnexoForm = Depends(), conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): return await processar_anexo(arquivo, dados, conn, usuario) @router.get('/ia/{id_anexo}') -def get_anexo_ia(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_contexto_ia)): +def get_anexo_ia(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): return consultar_anexo(conn, id_anexo, usuario.id_empresa) @router.get('/{id_anexo}') def get_anexo(id_anexo: int, conn=Depends(get_conn), usuario: UsuarioAutenticado = Depends(obter_usuario_autenticado)): - return consultar_anexo(conn, id_anexo, usuario.id_empresa) + return consultar_anexo(conn, id_anexo, usuario.id_empresa) \ No newline at end of file diff --git a/app/services/anexos.py b/app/services/anexos.py index d93e348..6df2f19 100644 --- a/app/services/anexos.py +++ b/app/services/anexos.py @@ -76,6 +76,12 @@ def _validar_ciclo_empresa(conn, id_ciclo: int, id_empresa: int) -> None: raise HTTPException(403, 'Ciclo não pertence à empresa do usuário') def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: str, usuario: UsuarioAutenticado) -> int: + tipo_arquivo = arquivo.content_type or extensao + + # Corrige mime type genérico + if extensao == 'txt' and tipo_arquivo == 'application/octet-stream': + tipo_arquivo = 'text/plain' + with conn.cursor() as cursor: cursor.execute("SELECT set_config('app.current_user_id', %s, true)", (str(usuario.id_usuario),)) cursor.execute(""" @@ -83,7 +89,7 @@ def _inserir_metadados(conn, arquivo: UploadFile, dados: AnexoForm, extensao: st VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NULL, %s, %s, %s) RETURNING id """, - (usuario.id_empresa, dados.id_ciclo, usuario.id_usuario, dados.id_origem, arquivo.filename, arquivo.content_type or extensao, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value)) + (usuario.id_empresa, dados.id_ciclo, usuario.id_usuario, dados.id_origem, arquivo.filename, tipo_arquivo, arquivo.size, 'cloudinary', dados.categoria.value, dados.descricao, Status.PROCESSANDO.value)) id_anexo = cursor.fetchone()[0] conn.commit() @@ -96,4 +102,4 @@ def _atualizar_status_erro(conn, id_anexo: int, id_usuario: int) -> None: (str(id_usuario),) ) cursor.execute('UPDATE pdca.anexo SET status = %s WHERE id = %s', (Status.ERRO.value, id_anexo)) - conn.commit() + conn.commit() \ No newline at end of file diff --git a/app/services/autenticacao.py b/app/services/autenticacao.py index 73b6588..13f7f4a 100644 --- a/app/services/autenticacao.py +++ b/app/services/autenticacao.py @@ -1,40 +1,10 @@ -from hmac import compare_digest -from os import getenv from httpx import AsyncClient, RequestError -from fastapi import Depends, Header, HTTPException, Security +from fastapi import HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -from app.database.postgres import get_conn +from app.config import ACTA_PG_API_URL from app.schemas import UsuarioAutenticado - -ACTA_PG_API_URL = getenv('ACTA_PG_API_URL') bearer = HTTPBearer() -async def obter_contexto_ia( - credenciais: HTTPAuthorizationCredentials = Security(bearer), - id_usuario: int = Header(..., alias='X-Acta-Usuario-Id', gt=0), - conn=Depends(get_conn) -) -> UsuarioAutenticado: - token_ia = getenv('ACTA_IA_TOKEN') - if not token_ia or not compare_digest(credenciais.credentials, token_ia): - raise HTTPException(401, 'Token da IA inválido') - - with conn.cursor() as cursor: - cursor.execute( - """ - SELECT id, id_empresa - FROM public.usuario_sistema - WHERE id = %s AND status = 'ATIVO' - """, - (id_usuario,) - ) - usuario = cursor.fetchone() - - if usuario is None: - raise HTTPException(403, 'Usuário da IA não autorizado') - - return UsuarioAutenticado(idUsuario=usuario[0], idEmpresa=usuario[1]) - async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = Security(bearer)) -> UsuarioAutenticado: headers = {'Authorization': f'Bearer {credenciais.credentials}'} @@ -56,6 +26,16 @@ async def obter_usuario_autenticado(credenciais: HTTPAuthorizationCredentials = raise HTTPException(502, 'Falha ao consultar usuário autenticado') try: - return UsuarioAutenticado.model_validate(resposta.json()) + dados_usuario = resposta.json() + if not isinstance(dados_usuario, dict): + raise ValueError + except (ValueError, TypeError): + raise HTTPException(502, 'Resposta inválida de acta-pg-api') + + if dados_usuario.get('status') != 'ATIVO': + raise HTTPException(403, 'Usuário não autorizado') + + try: + return UsuarioAutenticado.model_validate(dados_usuario) except (ValueError, TypeError): raise HTTPException(502, 'Resposta inválida de acta-pg-api') \ No newline at end of file diff --git a/app/services/cloudinary.py b/app/services/cloudinary.py index 64179b2..a179382 100644 --- a/app/services/cloudinary.py +++ b/app/services/cloudinary.py @@ -1,15 +1,13 @@ -from cloudinary import config +from cloudinary import config as cloudinary_config from cloudinary.uploader import upload -from os import getenv -from dotenv import load_dotenv +from app.config import CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, CLOUDINARY_CLOUD_NAME -load_dotenv() - -CLOUD_NAME = getenv('CLOUDINARY_CLOUD_NAME') -API_KEY = getenv('CLOUDINARY_API_KEY') -API_SECRET = getenv('CLOUDINARY_API_SECRET') - -config(cloud_name=CLOUD_NAME, api_key=API_KEY, api_secret=API_SECRET, secure=True) +cloudinary_config( + cloud_name=CLOUDINARY_CLOUD_NAME, + api_key=CLOUDINARY_API_KEY, + api_secret=CLOUDINARY_API_SECRET, + secure=True, +) # reforçar tipo raw, cloudinary não sabe lidar com esses arquivos EXTENSOES_RAW = {'docx', 'pptx', 'xlsx', 'csv', 'txt'}