ESP32-S3 Bench Guide
ESP32‑S3 N16R8 벤치 가이드: 실시간 핀아웃, 강의, 시뮬레이터 및 벤치 로그
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Treinador de inglês full-stack com quiz e feedback gerados por IA — a nota é sempre calculada no servidor, a partir do gabarito que está no banco, nunca de dado enviado pelo cliente.
🔗 Demo: https://app-ingles-bay.vercel.app — dá pra testar sem cadastro pelo botão Entrar como visitante 📦 Repo: https://github.com/Americanoooo/app-ingles
Depois do primeiro projeto de portfólio, eu queria sair da minha zona de conforto num ponto específico: nunca tinha me aprofundado em consumir uma API externa de verdade. IA generativa encaixou perfeitamente nessa meta — dava pra construir algo útil (um treino de inglês) enquanto aprendia, de ponta a ponta, a integrar com um provedor de IA: da chamada ao modelo até validar e persistir com segurança o que ele devolve.
Landing page — página pública de apresentação em /, com prévia de uma pergunta e da explicação da IA; quem já tem sessão válida é redirecionado direto para o treino.
Autenticação completa — cadastro e login com senha criptografada e sessão via cookie httpOnly (proteção contra roubo de token via XSS).
Modo visitante — o botão "Entrar como visitante" cria uma sessão numa conta compartilhada (criada pelo schema.sql), para quem quer testar sem se cadastrar.
Autorização por dono (anti-IDOR) — cada usuário só vê os próprios quizzes; id de outro dono na URL não vaza dado (404 genérico, também anti-enumeração).
Rate limiting — limites por IP nas rotas públicas (login/cadastro) e por usuário nas autenticadas, com contadores independentes para as rotas que chamam a IA (mais caras) e para o restante.
Geração de quiz por IA — escolha de dificuldade e quantidade de perguntas; a IA gera questões de múltipla escolha nas categorias preposição, tempo verbal e contexto.
Correção no servidor — o quiz é persistido no momento da geração e corrigido contra o gabarito do banco, dentro de uma transação. Cada quiz só pode ser respondido uma vez, e cada resposta precisa ser uma das opções da pergunta.
Tela de resultado — acertos destacados visualmente (verde/vermelho), com a resposta correta revelada apenas nas perguntas erradas.
Feedback por pergunta — explicação gerada por IA, em português, do porquê da resposta correta. O cliente envia só o id da pergunta; enunciado e respostas saem do banco.
Histórico de quizzes — relatório com filtros por dificuldade e período; revisão pergunta a pergunta de um quiz antigo.
Limpeza automática — rotina agendada diariamente remove quizzes gerados e nunca respondidos (consequência assumida do fluxo de persistir antes de corrigir) e zera o histórico da conta de visitante.
Front-end: Next.js 16 (App Router) · React · TypeScript · Tailwind CSS + shadcn/ui
Back-end: Next.js Route Handlers + Proxy (antigo middleware) · MySQL + Prisma 7 (adapter MariaDB) · Zod · bcrypt · jose (JWT em cookie httpOnly)
IA: Google Gemini (endpoint compatível com OpenAI), saída estruturada via json_schema
Infraestrutura: Docker + Docker Compose · Upstash Redis (rate limiting) · Vercel Cron · Deploy: Vercel (app) + Aiven (MySQL)
Falha de segurança que eu encontrei e corrigi: nota forjável pelo cliente. Na primeira versão, o quiz só era gravado quando o usuário respondia — o que significa que, na hora de corrigir, o /api/responder recebia a resposta certa de cada pergunta no mesmo body que o cliente reenviava. O servidor até calculava a nota (o cliente não mandava acertou: true), mas comparava dois valores que o próprio cliente controlava: bastava abrir o DevTools, ler o gabarito e reenviar respostaUsuario = respostaCerta pra forçar 10/10.
A correção mudou o fluxo: /api/gerar-perguntas passou a persistir quiz e perguntas (com o gabarito) no momento da geração, dentro de uma transação, e devolve pro front apenas quizId, os ids das perguntas, enunciados e opções — a resposta certa nunca sai do servidor. O /api/responder recebe só quizId e os pares {perguntaId, respostaUsuario}, busca o gabarito real no banco com checagem de dono (JOIN por usuario_id), calcula acerto e nota, e faz o UPDATE. Nada que o cliente envie participa da decisão do que é certo — e, de quebra, o gabarito deixou de ficar visível no DevTools durante o quiz.
Fechando as brechas que sobraram no mesmo fluxo. Com o gabarito no servidor, ainda dava pra abusar das rotas de outras formas, e cada uma ganhou uma regra própria:
/api/responder rejeita qualquer respostaUsuario que não esteja entre as opções gravadas para aquela pergunta — o banco só recebe respostas que o usuário poderia ter clicado.UPDATE da nota exigem nota IS NULL. Se duas requisições chegarem juntas, só uma consegue atualizar a linha (affectedRows === 0 na outra, que faz rollback e devolve 404). A checagem fica no próprio UPDATE, e não num SELECT anterior, justamente para não abrir uma janela entre ler e gravar./api/feedback recebia enunciado e respostas no body e montava o prompt com eles — qualquer um podia usar a rota como um proxy gratuito para o Gemini, com o texto que quisesse. Agora a rota recebe só o perguntaId e busca a pergunta no banco, filtrando pelo dono e por quizzes já respondidos.Trade-off que eu aceitei conscientemente, e o que fiz com ele. Persistir o quiz antes de existir resposta significa aceitar linhas incompletas no banco: nota, resposta_usuario e acertou nascem nulos e só são preenchidos quando o usuário envia. Além de exigir que essas colunas fossem nullable, isso cria um efeito colateral: quizzes gerados e nunca respondidos ficam guardados. A alternativa (segurar tudo em memória até o envio) manteria o banco limpo, mas não sobrevive a um ambiente serverless, onde a próxima requisição pode cair em outra instância. Escolhi o dado incompleto em vez da falha de segurança, e lidei com as consequências em dois lugares: o relatório filtra por nota IS NOT NULL, e uma rotina agendada apaga os abandonados.
A rotina de limpeza. É uma rota (/api/limpeza) invocada por um Vercel Cron uma vez por dia. Faz duas coisas: apaga quizzes abandonados de todos os usuários e zera o histórico da conta de visitante. Quatro decisões que valem explicação:
Authorization com a variável CRON_SECRET — e por isso ela também fica de fora do matcher do proxy, que exige sessão. São dois mecanismos de identidade para dois tipos de cliente. Se CRON_SECRET não estiver definida, a rota recusa tudo em vez de aceitar um header vazio.criarQuiz, onde o estado intermediário seria visível para o usuário, precisa.data guarda só o dia, sem hora, então "abandonado" precisa ser definido em dias. A primeira versão apagava tudo que fosse anterior ao dia corrente, achando que isso protegia quem estava respondendo — mas um quiz começado às 23h já é "de ontem" quando o cron roda à 01h, e seria apagado no meio da resposta. Hoje o corte é anterior a ontem: todo quiz sobrevive pelo menos 25h antes de ser considerado abandonado. O "hoje" é calculado no código, no fuso de São Paulo, e não com o CURDATE() do MySQL, para a regra não depender do fuso configurado no servidor do banco.Saída estruturada da IA (json_schema). Resposta de LLM em texto livre é inconsistente na prática — e isso não é só estética: um parse que falha vira bug real na experiência do usuário (quiz não carrega, feedback trava). Por isso a geração das perguntas e o feedback por pergunta usam saída estruturada (json_schema, na chamada compatível com OpenAI ao Gemini) — o schema trava os campos que a IA precisa devolver, então o código lê cada um com confiança em vez de tentar adivinhar texto solto. Como camada extra, a resposta ainda passa por validação com Zod antes de qualquer gravação no banco — defesa em profundidade: o schema garante o formato na saída do LLM, o Zod garante que o que chega no back-end bate com o que o banco espera, mesmo que o provedor mude de comportamento.
Persistência atômica. Quiz e perguntas precisam ser gravados como uma unidade: se a inserção de uma pergunta falhar no meio do caminho, sobra um quiz órfão no banco. No criarQuiz, quiz e perguntas vão num único create com escrita aninhada, que o Prisma executa de forma atômica. No atualizarQuiz, a transação é interativa (prisma.$transaction(async (tx) => …)) porque precisa decidir no meio do caminho: se o UPDATE do quiz não encontrou nada (já respondido ou de outro usuário), lança erro e desfaz tudo. A regra que mais importa ali é usar sempre o tx dentro do callback, nunca o prisma — uma query feita pelo prisma roda em outra conexão, fora da transação, e o rollback não teria efeito nenhum sobre ela. Quando as operações são uma sequência fixa, sem decisão no meio (como a limpeza do visitante), uso a forma em array, prisma.$transaction([...]).
Uma convenção de nomes, convertida num lugar só. O banco usa snake_case e o restante da aplicação usa camelCase. A conversão acontece dentro dos models (lib/models/*.model.ts), logo depois da query — no caso das perguntas, numa função só (paraPerguntaQuiz) usada por todas as buscas: rotas e front nunca veem nome de coluna. Antes disso, uma rota devolvia a linha crua do banco enquanto outras já convertiam, e o mesmo dado chegava com dois formatos diferentes dependendo da tela — origem de vários bugs silenciosos, porque o TypeScript não acusa quando a interface descreve um formato que o dado real não tem.
Erro tratado por camada. O model lança, a rota traduz para HTTP e o front exibe. As funções em lib/respostas.ts concentram os status e as mensagens voltadas ao usuário; o detalhe técnico vai só para o log. No cliente, o apiFetch converte qualquer resposta com status de erro em um ApiError que carrega status e mensagem, e a tela usa instanceof para distinguir "o servidor respondeu com erro" de "a requisição nem chegou lá" — só no primeiro caso a mensagem do backend é exibida. No proxy (proxy.ts, o antigo middleware do Next.js), a mesma ideia separa o que é falha de autenticação (bloqueia) do que é falha de infraestrutura do rate limit (registra no log e deixa passar).
Pré-requisitos: Docker instalado.
git clone https://github.com/Americanoooo/app-ingles.git
cd app-ingles
# crie o .env (veja "Variáveis de ambiente")
docker compose up --build
Acesse http://localhost:3001 — o schema do banco (incluindo a conta de visitante) é criado automaticamente na primeira execução. O MySQL do container fica exposto na porta 3307 do host.
Pré-requisitos: Node.js, MySQL local com o schema criado (schema.sql).
npm install
npx prisma generate # gera o client do Prisma em lib/generated (fica fora do git)
# configure o .env apontando pro seu MySQL local
npm run dev
Acesse http://localhost:3000.
DB_HOST=localhost # use "db" ao rodar via Docker Compose
DB_USER=root
DB_PASSWORD=sua_senha
DB_NAME=app_ingles
DB_PORT=3306
JWT_SECRET=uma_chave_secreta_longa
GEMINI_API_KEY=sua_chave_do_gemini
UPSTASH_REDIS_REST_URL=url_do_seu_redis
UPSTASH_REDIS_REST_TOKEN=token_do_seu_redis
CRON_SECRET=string_aleatoria_longa # autentica a rota de limpeza
O
.envestá no.gitignoree não é versionado. A chave do Gemini é gratuita no Google AI Studio.
app/
├── page.tsx # landing page (pública)
├── (auth)/ # login/cadastro (público)
├── (app)/ # treino, relatório (autenticado) + layout com Navbar
├── api/ # route handlers
├── components/ # componentes do app (AuthGuard, Navbar, landing/…)
├── opengraph-image.tsx # imagem de compartilhamento gerada no build
components/
├── ui/ # shadcn/ui
lib/
├── models/ # acesso ao banco via Prisma + conversão snake_case → camelCase
├── prisma.ts # client do Prisma (adapter MariaDB)
└── … # sessão, respostas HTTP, datas, rate limit
prisma/schema.prisma # modelos do banco
prisma.config.ts # config da CLI do Prisma
proxy.ts # sessão + rate limit das rotas de API (antigo middleware)
schema.sql # estrutura do banco + conta de visitante
vercel.json # agendamento do cron de limpeza
docker-compose.yml
dockerfile
data como DATETIME — hoje a coluna guarda só o dia, o que limita a ordenação do histórico e obriga a limpeza a esperar pelo menos 25h para considerar um quiz abandonado.Cauã Americano — GitHub · LinkedIn
Full-stack English trainer with AI-generated quizzes and per-question feedback (Next.js 16 App Router, TypeScript, MySQL via Prisma 7), with a public landing page and a guest mode for trying it without signing up. The server is the single source of truth for scoring: quizzes and questions — including the correct answers — are persisted when the quiz is generated, and /api/responder grades submissions against the database instead of trusting anything the client sends back. This closed a real vulnerability in v1, where the answer key round-tripped through the client and a forged 10/10 was possible from DevTools. The same flow also rejects answers that aren't among the question's options, blocks answering a quiz twice (the check lives in the UPDATE itself, so concurrent requests can't both win), and the feedback route receives only a question id and builds the prompt from the database, so the client can't drive the LLM. The accepted trade-off — rows that start incomplete, and abandoned quizzes that pile up — is handled by a daily Vercel Cron cleanup route, authenticated by a shared secret rather than by session, which also wipes the shared guest account's history. Questions and feedback come from Google Gemini (OpenAI-compatible endpoint) using structured output (json_schema), validated again with Zod before reaching the database. Multi-step writes run inside Prisma transactions, so a failure halfway leaves nothing behind. Session auth via httpOnly cookie (jose); ownership-based authorization prevents IDOR across users' quiz history; rate limiting is applied per IP on public routes and per user on authenticated ones.
Demo: https://app-ingles-bay.vercel.app · Repo: github.com/Americanoooo/app-ingles