O RelataUFSC é um MVP em produção para relatar problemas visíveis de infraestrutura nos campi da UFSC por meio de um mapa público. A aplicação roda como um único container Next.js, com Postgres/Supabase, uploads persistidos em disco, moderação por Telegram e e-mail transacional opcional via Brevo SMTP.
- Sem login
- Sem painel público de administração
- Sem contas de usuário
- Sem Redis
- Sem divisão entre frontend e backend em deploys separados
- Apenas relatos aprovados aparecem publicamente
- Relatos pendentes e rejeitados nunca chegam às APIs públicas
O schema Postgres é propositalmente mínimo e orientado à privacidade.
Tabela principal: complaints
idcampus_iddescriptionlatitudelongitudemedia_pathmedia_kindmedia_mime_typemedia_size_bytespublic_namestatuscreated_atmoderated_atapproved_atsubmitter_email
Regra de privacidade:
submitter_emailsó existe enquanto o relato estiver pendente.- Na aprovação ou rejeição, o fluxo de moderação zera
submitter_emailimediatamente no banco antes de encerrar o processamento.
Tabela mínima anti-spam: submission_rate_limits
key_hashcreated_atexpires_at
Essa tabela guarda apenas chaves temporárias com hash. Não há histórico bruto de IP nem fingerprinting invasivo.
Referências de código:
- Schema:
src/db/schema.ts - Migração de endurecimento:
drizzle/0001_harden_mvp_persistence.sql - Repositório de relatos:
src/db/repositories/complaints-repository.ts - Repositório de rate limit:
src/db/repositories/submission-rate-limits-repository.ts
O Telegram é a porta de entrada da moderação. O site público não possui poderes administrativos.
Fluxo:
- O envio público entra em
POST /api/report - O servidor valida o payload e salva o relato como
pending - O e-mail opcional é mantido apenas enquanto o relato estiver pendente
- O bot do Telegram envia uma mensagem de moderação com links assinados e expiráveis
- O link do Telegram abre uma página de confirmação em
/moderate/[action] - A mudança real de estado acontece somente em:
POST /api/moderate/approvePOST /api/moderate/reject
- A aprovação ou rejeição tenta enviar um e-mail transacional de status via Brevo SMTP, se existir e-mail
- A rejeição mantém o relato fora da pipeline pública
- Em ambos os casos,
submitter_emailé removido imediatamente durante a moderação
Propriedades de segurança:
TELEGRAM_BOT_TOKENeTELEGRAM_CHAT_IDficam apenas no servidor- Tokens de moderação são assinados com HMAC e expiram
- Os tokens são específicos por ação:
approve,reject,preview - Links inválidos, expirados ou reutilizados falham de forma segura
- Ações repetidas são bloqueadas pela regra de transição do estado
pending - Mídia pendente só é servida por rota protegida com token de preview válido
- APIs públicas nunca retornam
submitter_email, tokens de moderação, caminhos internos de storage ou dados do Telegram
Referências de código:
- Tokens:
src/services/tokens.ts - Moderação:
src/services/moderation.ts - Telegram:
src/services/telegram.ts - Preview protegido:
src/app/api/moderate/media/[id]/route.ts - Página de confirmação:
src/app/moderate/[action]/page.tsx
Rotas públicas:
GET /api/public/complaints?campusId=...GET /api/public/statsPOST /api/report
Payload público mínimo:
idcampusIddescriptionlatitudelongitudemediaUrlmediaKindmediaMimeTypepublicNamedisplayNamepublishedAtapproximateLocationLabel
Nunca retornado publicamente:
submitter_email- relatos pendentes ou rejeitados
- tokens de moderação
- metadados do Telegram
- caminhos internos de storage
A Brevo é usada apenas para e-mail transacional de status via SMTP.
Regra pró-privacidade:
- Se houver e-mail, o sistema tenta enviar a mensagem após a decisão de moderação.
- O campo
submitter_emailjá foi zerado no banco antes do fim dessa tentativa. - Se o envio falhar, o e-mail continua não sendo retido.
Referência:
src/services/email.ts
A raiz persistente foi desenhada para /app/data.
Estrutura recomendada:
/app/data/uploads/pending/.../app/data/uploads/public/...
Política:
- Uploads novos entram em
pending/ - Na aprovação, os arquivos vão para
public/ - Na rejeição, os arquivos pendentes são apagados
- A mídia pública é servida somente por
/media/[...path] - A mídia pendente nunca é enumerável publicamente
Referência:
src/services/storage.ts
Copie .env.example:
cp .env.example .envPrincipais variáveis:
APP_URLAPP_NAMEPOSTGRES_URLMOCK_MODEDATA_DIRUPLOAD_PENDING_DIRUPLOAD_PUBLIC_DIRMAX_UPLOAD_SIZE_MBTELEGRAM_BOT_TOKENTELEGRAM_CHAT_IDMODERATION_SECRETMODERATION_TOKEN_TTL_MINUTESBREVO_SMTP_HOSTBREVO_SMTP_PORTBREVO_SMTP_LOGINBREVO_SMTP_PASSWORDBREVO_SMTP_SECUREBREVO_SENDER_EMAILBREVO_SENDER_NAMESUBMISSION_RATE_LIMIT_WINDOW_SECONDSSUBMISSION_RATE_LIMIT_MAX_ATTEMPTS
Notas:
POSTGRES_URLé obrigatória em runtime e deve apontar para o Postgres do Supabase.- Prefira a URL do pooler (
6543) comsslmode=requireepgbouncer=true. - Para rodar localmente sem banco real, use
MOCK_MODE=truee deixePOSTGRES_URLvazia.
Para abrir a aplicação localmente sem Postgres, Telegram ou Brevo:
- Ajuste o
.envlocal:
NODE_ENV=development
APP_URL=http://localhost:5000
MOCK_MODE=true
NEXT_PUBLIC_MOCK_MODE=true
POSTGRES_URL=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
BREVO_SMTP_LOGIN=
BREVO_SMTP_PASSWORD=
PORT=5000- Rode a aplicação:
npm install
npx next dev -p 5000Nesse modo:
- os relatos públicos vêm de memória com dados demonstrativos
- novos envios funcionam localmente sem moderação real
- uploads enviados pelo formulário continuam funcionando localmente
- Telegram, banco e rate limit persistente são ignorados
Instale as dependências:
npm installInicialize diretórios locais e migrações:
npm run db:bootstrapSuba a aplicação:
npm run devComandos úteis:
npm run db:migrate
npm run db:seed
npm run test
npm run typecheck
npm run lint
npm run buildBuild:
docker build -t ufsc-relata .Execução:
docker run --rm -p 5000:5000 \
--env-file .env \
-v "$(pwd)/data:/app/data" \
ufsc-relataOs defaults do container estão alinhados com:
DATA_DIR=/app/dataUPLOAD_PENDING_DIR=/app/data/uploads/pendingUPLOAD_PUBLIC_DIR=/app/data/uploads/publicPORT=5000
O container roda com o usuário não-root node. O volume montado precisa ser gravável por esse usuário, ou por uma configuração de permissões compatível no host.
Configuração recomendada no Dokploy:
- Faça o deploy a partir do
Dockerfileincluído no projeto - Configure as variáveis de ambiente com base em
.env.example - Monte um volume persistente em
/app/datapara uploads - Exponha a porta
5000 - Defina
APP_URLcom a URL HTTPS final do site - Configure
POSTGRES_URLcom a URL do pooler do Supabase - Garanta que o volume persistente seja gravável pelo usuário do container
Comportamento operacional:
- No primeiro boot, a app cria
/app/data/uploads/pendinge/app/data/uploads/publiccaso não existam - As migrações rodam automaticamente no bootstrap de inicialização
- A execução oficial nunca publica dados mockados do banco
- Registros antigos de demonstração com IDs reservados (
demo-*emock_*) são limpos no bootstrap
- O visitante envia um relato pelo mapa
- O backend salva o relato como pendente
- O Telegram recebe uma mensagem em português com protocolo, nome informado, e-mail opcional, descrição, horário de envio, arquivo e preview protegido da mídia, quando existir
- Os botões de aprovação e rejeição usam links assinados e expiráveis
- Ao aprovar:
- o relato fica público
- um e-mail de status é tentado se existir
- o e-mail é removido da persistência em seguida
- Ao rejeitar:
- o relato segue fora da área pública
- um e-mail de status é tentado se existir
- o e-mail é removido imediatamente após a moderação
- Relatos pendentes não aparecem em
GET /api/public/complaints - APIs públicas nunca retornam
submitter_email - Links de moderação expiram e são assinados
- Tokens inválidos falham com segurança
- Ações repetidas de aprovação ou rejeição falham com segurança
- Segredos do Telegram permanecem apenas no servidor
- Mídia pendente é protegida por rota assinada de preview
- Mídia aprovada só fica pública após a moderação movê-la para
public/ - O e-mail de status é tentado na aprovação ou rejeição, se existir
submitter_emailé removido da persistência após a moderação
Os testes leves cobrem os pontos mais importantes das primitivas de segurança:
src/services/tokens.test.tssrc/services/storage.test.ts