Guarde as receitas da família num só lugar, compartilhe em grupos, e siga o modo de preparo com a tela sempre acesa e cronômetros automáticos enquanto cozinha.
- O que é
- Destaques
- Demonstração das telas
- Arquitetura
- Modelo de dados
- Fluxo de autenticação
- Como rodar
- Variáveis de ambiente
- Referência da API
- Segurança
- Testes
- Estrutura do projeto
- Deploy
- Licença
RecipeBox é um PWA (aplicativo web instalável) para guardar e compartilhar receitas. Nasceu para uso de família e amigos: cada pessoa tem sua conta, cria receitas pessoais ou privadas, e monta grupos para compartilhar coleções — o clássico "receitas da vovó" que todo mundo quer ter à mão.
O foco não é só guardar a receita, é cozinhar a partir dela. Por isso o modo cozinha, o checklist com progresso salvo e os cronômetros. E como PWA, funciona offline: as receitas já abertas continuam disponíveis mesmo sem sinal.
mindmap
root((🍳 RecipeBox))
Receitas
Ingredientes com quantidade
Passos em seções
Foto otimizada
Dificuldade e tempos
Grupos
Dono e membros
Compartilhar coleções
Exportar o livro inteiro
Cozinhar
Modo cozinha
Tela sempre acesa
Cronômetro automático
Progresso salvo
Levar junto
PWA offline
Excel e Word
Imprimir
| Recurso | Descrição | |
|---|---|---|
| 👩🍳 | Modo Cozinha | Tela cheia, passos grandes, Wake Lock para a tela não apagar e cronômetro automático quando o passo menciona um tempo ("asse por 40 minutos") |
| ✅ | Checklist com progresso | Marque ingredientes e passos; o progresso fica salvo no dispositivo mesmo que você feche o app |
| 👥 | Grupos | Crie grupos, adicione membros por usuário e compartilhe receitas — com papéis de dono e membro |
| 📷 | Fotos otimizadas | A imagem é redimensionada no navegador antes de subir (WebP, ~80 KB) — sem depender de sharp no servidor |
| 🔎 | Busca real | Busca no servidor por título, categoria e ingredientes, com filtro por categoria e ordenação |
| 🔐 | Cadastro por convite | Cadastro público protegido por código de convite — grupo fechado sem precisar criar conta manualmente |
| 📤 | Exportar / Importar | Exporte para Excel e Word (receita ou grupo inteiro); importe uma planilha de receitas de uma vez |
| 🌙 | Tema claro/escuro | Segue a preferência do sistema, com alternância manual |
| 📱 | PWA offline | Instalável no celular, com service worker que serve o app e as fotos sem conexão |
| ♿ | Acessível | Navegação por teclado, foco visível, rótulos ARIA e suporte a reduced motion |
As telas são renderizadas 100% no cliente a partir da mesma paleta (laranja
#E8652D). Rodenpm run seed && npm run deve acessehttp://localhost:3000para explorar com dados de exemplo.
┌─────────────────────────────┐ ┌─────────────────────────────┐ ┌─────────────────────────────┐
│ 🍳 RecipeBox 🌙 ⏻ │ │ ← Bolo de Cenoura │ │ ✕ Bolo de Cenoura │
│ ┌─────────────────────────┐ │ │ ┌───────────────────────┐ │ │ 💡 Tela sempre acesa │
│ │ 🔍 farinha ✕ │ │ │ │ [ foto ] │ │ │ ▓▓▓▓▓▓░░░░ 3/6 │
│ └─────────────────────────┘ │ │ └───────────────────────┘ │ │ │
│ [Todas][Privadas][Grupos] │ │ ⏱20min 🍽12 ⭐⭐☆ │ │ # Massa │
│ Bolos ▾ Recentes ▾ │ │ │ │ ┌───────────────────────┐ │
│ ┌─────────────────────────┐ │ │ 👩🍳 MODO COZINHA │ │ │ ☑ 1 Bata a cenoura… │ │
│ │ [foto] 🏷Bolos ⭐⭐☆ │ │ │ │ │ │ ☑ 2 Acrescente… │ │
│ │ Bolo de Cenoura │ │ │ 📝 Ingredientes │ │ │ ☐ 3 Misture ⏱2:00 │ │
│ │ Fofinho com cobertura │ │ │ ☐ 3 un cenoura ralada │ │ └───────────────────────┘ │
│ │ ⏱1h 🍽12 │ │ │ ☐ 4 un ovos │ │ # Cobertura │
│ └─────────────────────────┘ │ │ 👩🍳 Modo de Preparo │ │ ┌───────────────────────┐ │
│ (+) │ │ ▓▓▓▓░░░░ 2/6 passos │ │ │ ⏱ 1:47 [Cancelar] │ │
│ 📖 Receitas 👥 Grupos │ │ ✏️ Editar 🗑️ 🖨️ 📄 │ │ └───────────────────────┘ │
└─────────────────────────────┘ └─────────────────────────────┘ └─────────────────────────────┘
Dashboard Detalhe da receita Modo Cozinha
Monólito enxuto: um servidor Express serve tanto a API JSON quanto o front estático (PWA em ES Modules, sem build step). O banco é libSQL — o mesmo código roda em SQLite local no desenvolvimento e em Turso na nuvem.
flowchart TB
subgraph Cliente["📱 Cliente — PWA (ES Modules)"]
direction TB
SW["Service Worker<br/>cache do app + fotos"]
UI["Páginas<br/>login · dashboard · receita<br/>modo cozinha · grupos"]
LIB["Libs<br/>resize de imagem · wake lock<br/>timer · parser de receita"]
API_C["api.js<br/>fetch + JWT + cache de imagens"]
UI --> API_C
LIB --> UI
end
subgraph Servidor["🖥 Servidor — Express"]
direction TB
MW["Middlewares<br/>helmet · CORS · rate-limit<br/>auth JWT · error handler"]
R1["/api/auth"]
R2["/api/recipes"]
R3["/api/groups"]
R4["/api/export · /api/import"]
PERM["lib/permissions<br/>(toda autorização passa aqui)"]
MW --> R1 & R2 & R3 & R4
R2 & R3 & R4 --> PERM
end
DB[("💾 libSQL<br/>SQLite local ou Turso")]
API_C -- "HTTPS + Bearer JWT" --> MW
SW -. "intercepta GET" .-> API_C
PERM --> DB
R1 --> DB
classDef client fill:#FDE8DD,stroke:#E8652D,color:#2D2D2D
classDef server fill:#EBF5FB,stroke:#3498DB,color:#2D2D2D
classDef db fill:#E8F8EF,stroke:#27AE60,color:#2D2D2D
class SW,UI,LIB,API_C client
class MW,R1,R2,R3,R4,PERM server
class DB db
Princípio central: toda decisão de autorização ("posso ver/editar isto?") vive em
lib/permissions.js. Antes, essa checagem estava copiada em cada rota — e foi exatamente uma
cópia esquecida que abriu uma falha de segurança (veja Segurança).
erDiagram
users ||--o{ recipes : "cria"
users ||--o{ groups_ : "cria"
users ||--o{ group_members : "participa"
groups_ ||--o{ group_members : "tem"
groups_ ||--o{ recipes : "contém"
recipes ||--o| recipe_images : "tem foto"
users {
text id PK
text username UK
text password_hash
text name
}
groups_ {
text id PK
text name
text created_by FK
}
group_members {
text id PK
text group_id FK
text user_id FK
text role "owner | member"
}
recipes {
text id PK
text title
text ingredients "linhas: 'qtd | nome'"
text instructions "linhas + '# seção'"
integer is_private
text difficulty
text user_id FK
text group_id FK
}
recipe_images {
text recipe_id PK
blob thumb "256px WebP"
blob full "1024px WebP"
}
As fotos ficam numa tabela separada de propósito: se morassem na linha da receita, toda
listagem arrastaria os binários junto. A listagem devolve apenas um sinalizador has_image.
sequenceDiagram
autonumber
actor U as Usuário
participant F as Front (PWA)
participant A as /api/auth
participant DB as libSQL
Note over U,DB: Cadastro (protegido por convite)
U->>F: preenche usuário, senha e código de convite
F->>A: POST /register
A->>A: compara convite (timing-safe)
alt convite inválido
A-->>F: 403 Código inválido
else convite válido
A->>A: bcrypt (cost 12) na senha
A->>DB: INSERT user
A-->>F: 201 { token JWT, user }
end
Note over U,DB: Login e uso
U->>F: usuário + senha
F->>A: POST /login (máx. 5 / 15 min)
A->>DB: SELECT user
A->>A: bcrypt.compare
A-->>F: { token JWT (7 dias) }
F->>F: guarda o token no localStorage
Note over F,DB: Requisições seguintes
F->>A: GET /recipes (Authorization: Bearer …)
A->>A: verifica assinatura do JWT
A-->>F: dados
Pré-requisitos: Node.js ≥ 18.
# 1. Instale as dependências
npm install
# 2. Crie o .env a partir do exemplo
cp .env.example .env
# 3. Gere um segredo forte para o JWT e cole no .env (JWT_SECRET)
npm run secret
# 4. Defina um INVITE_CODE no .env (qualquer texto secreto)
# — é ele que libera o cadastro de novas contas
# 5. (Opcional) Popule com dados de exemplo
npm run seed # cria admin / 123456 e o grupo "Família Silva"
# 6. Suba o servidor
npm run dev # com --watch (recarrega ao salvar)
# ou
npm startAcesse http://localhost:3000.
Primeiro usuário sem convite? Use
npm run create-user— um assistente interativo que pergunta usuário e senha (nada fica salvo em código).
| Variável | Obrigatória | Padrão | Descrição |
|---|---|---|---|
JWT_SECRET |
✅ | — | Segredo de assinatura do JWT. Mínimo 32 caracteres — o servidor não sobe sem isso. Gere com npm run secret |
INVITE_CODE |
— | (vazio) | Código exigido no cadastro. Vazio = cadastro público desativado |
DATABASE_URL |
— | file:./data.db |
SQLite local (file:…) ou Turso (libsql://…) |
TURSO_AUTH_TOKEN |
— | — | Token do Turso (só ao usar Turso) |
PORT |
— | 3000 |
Porta do servidor |
NODE_ENV |
— | development |
production desativa logs verbosos e o seed |
CORS_ORIGIN |
— | (vazio) | Origens permitidas se o front for hospedado à parte. Vazio = só mesma origem |
Todas as rotas (exceto /login, /register e /config) exigem o header
Authorization: Bearer <token>. 22 endpoints no total.
| Método | Rota | Descrição |
|---|---|---|
POST |
/login |
Login. Body: { username, password } → { token, user }. Limite: 5 / 15 min |
POST |
/register |
Cadastro. Body: { username, password, name, invite_code }. Limite: 3 / h |
GET |
/me |
Dados do usuário autenticado |
GET |
/config |
{ registration_enabled } — o front esconde o cadastro se estiver desligado |
| Método | Rota | Descrição |
|---|---|---|
GET |
/ |
Lista. Query: type (private/group), group_id, q, category, sort (recent/title/time) |
GET |
/categories |
Categorias distintas visíveis ao usuário |
GET |
/:id |
Detalhe de uma receita |
POST |
/ |
Cria uma receita |
PUT |
/:id |
Edita (apenas o dono) |
DELETE |
/:id |
Exclui (apenas o dono) |
GET |
/:id/image?size=thumb|full |
Foto da receita (com ETag) |
PUT |
/:id/image |
Salva a foto. Body: { thumb, full } (data URLs WebP) |
DELETE |
/:id/image |
Remove a foto |
| Método | Rota | Descrição |
|---|---|---|
GET |
/ |
Meus grupos, com contagem de membros e receitas |
POST |
/ |
Cria um grupo (você vira dono) |
GET |
/:id |
Detalhe + membros |
POST |
/:id/members |
Adiciona membro por username (só o dono) |
DELETE |
/:id/members/:userId |
Remove membro (só o dono; o dono não pode se remover) |
| Método | Rota | Descrição |
|---|---|---|
GET |
/api/export/excel |
Exporta receitas para .xlsx (query opcional group_id) |
GET |
/api/export/word/:id |
Exporta uma receita para .docx |
GET |
/api/export/word/group/:groupId |
Exporta o livro do grupo para .docx |
POST |
/api/import/excel |
Importa receitas de uma planilha (multipart, campo file) |
Exemplo — criar uma receita via curl
TOKEN="seu-jwt-aqui"
curl -X POST http://localhost:3000/api/recipes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Bolo de Cenoura",
"category": "Bolos",
"difficulty": "Fácil",
"prep_time": 20,
"cook_time": 40,
"servings": 12,
"ingredients": "3 un | cenoura ralada\n4 un | ovos\n2 xícaras | farinha",
"instructions": "# Massa\nBata a cenoura, os ovos e o óleo.\nAsse por 40 minutos."
}'O formato dos campos:
- Ingredientes: uma linha por item,
quantidade | nome(a barra é opcional). - Modo de preparo: uma linha por passo; linhas começando com
#viram títulos de seção.
Este projeto passou por um endurecimento focado em uso real. As correções mais relevantes:
| Área | O que era | O que ficou |
|---|---|---|
| XSS armazenado | HTML montado com onclick="fn('${título}')" e um escape que não tratava aspas — um título com ' executava código no navegador de outros membros |
Zero onclick inline (delegação por data-action) + CSP com script-src 'self' + escape completo. Uma regressão é bloqueada em três camadas |
| Autorização | PUT /recipes/:id não checava o grupo de destino (o POST checava) — dava para plantar receita em grupo alheio |
Toda autorização centralizada em lib/permissions.js; teste de regressão cobre o caso |
| Credenciais | Usuário e senha fixos em scripts/create-user.js |
CLI interativo, nada em código; senha fora do repositório |
| Força bruta | Login sem limite | express-rate-limit: 5 logins / 15 min, 3 cadastros / h |
| Cabeçalhos | Nenhum | helmet com CSP, X-Frame-Options, nosniff, sem X-Powered-By |
| Upload | multer sem limite — um arquivo grande derrubava o processo |
Limite de 5 MB, filtro de extensão, validação de magic bytes na imagem |
| Segredos | JWT_SECRET só falhava no primeiro login |
Validado no boot — o servidor não sobe com segredo fraco |
| Dependências | xlsx com prototype pollution + ReDoS (alta severidade) |
Atualizado para a versão corrigida da SheetJS · npm audit limpo |
🔍 O comparador do código de convite usa
timingSafeEqual— uma comparação com===vazaria o código caractere a caractere pelo tempo de resposta.
39 testes com o runner nativo do Node (node:test) + supertest — sem framework externo.
npm testpie showData
title Testes por área (39 no total)
"Receitas · busca · fotos" : 17
"Autorização" : 11
"Autenticação" : 7
"XSS" : 4
O foco é o que é perigoso: fronteiras de permissão, o bug de autorização já corrigido (com teste de regressão), vazamento de receita privada, e a neutralização do XSS. Dois testes falham de propósito no código antigo — são a prova de que a correção é real.
A CI (GitHub Actions) roda a suíte no Node 18, 20 e 22, mais npm audit.
recipebox/
├── server.js # ponto de entrada (só initDB + listen)
├── app.js # monta o Express (testável, sem abrir porta)
├── config.js # lê e valida o ambiente no boot
├── db.js # schema + índices (libSQL)
├── lib/
│ ├── permissions.js # ⭐ toda a autorização mora aqui
│ ├── validate.js # validação e sanitização de entrada
│ ├── http-error.js # erros com status HTTP
│ └── image.js # validação de imagem no servidor
├── middleware/
│ ├── auth.js # verificação do JWT
│ └── error.js # asyncHandler + tratador central de erros
├── routes/ # auth · recipes · groups · export · import
├── scripts/ # create-user · seed · generate-template · migrate
├── test/ # 39 testes (node:test + supertest)
└── public/ # o PWA — sem build step
├── index.html
├── manifest.json · sw.js · icon.svg
├── css/style.css
└── js/
├── main.js # boot, rotas, ações
├── api.js · state.js · router.js · ui.js
├── lib/ # image · timer · wake-lock · recipe-format
└── pages/ # login · dashboard · recipe-detail
# recipe-form · cooking-mode · groups
O app é serverless-friendly (as fotos vão para o banco, não para o disco).
Turso + qualquer plataforma Node:
- Crie um banco no Turso e pegue a URL e o token.
- Configure as variáveis de ambiente na plataforma (Vercel, Fly, Render…):
DATABASE_URL,TURSO_AUTH_TOKEN,JWT_SECRET,INVITE_CODE. - Comando de start:
npm start.
Em plataformas atrás de proxy, o app já chama
trust proxypara o rate-limit enxergar o IP real do usuário. Rode o primeironpm run create-userapontando para o banco de produção (ou use oINVITE_CODEpara se cadastrar pela tela).
MIT © 2026 LuisMarchio03
Feito com 🧡 para quem gosta de cozinhar em família.