From 7a0c12f8d24d5233f8a1be64b191aa08c769be08 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Sun, 8 Mar 2026 12:40:51 -0300 Subject: [PATCH] feat: api separada do front-end e area do desenvolvedor. --- .dockerignore | 54 +++ .github/workflows/deploy.yml | 7 +- .gitignore | 5 + .../DevTutoriais/formatos-de-imagem.es-PY.md | 129 ++++++ .../DevTutoriais/formatos-de-imagem.pt-BR.md | 129 ++++++ .../gerando-qrcodes-pela-api.es-PY.md | 186 +++++++++ .../gerando-qrcodes-pela-api.pt-BR.md | 186 +++++++++ .../qr-code-pix-estatico.es-PY.md | 142 +++++++ .../qr-code-pix-estatico.pt-BR.md | 142 +++++++ Controllers/DevTutoriaisController.cs | 63 +++ Controllers/DeveloperController.cs | 148 +++++++ Controllers/HealthController.cs | 7 +- Controllers/QRController.cs | 202 +++------- Controllers/QRManagerController.cs | 151 +++++++ Controllers/TutoriaisController.cs | 16 +- Filters/ApiKeyAuthorizeAttribute.cs | 162 ++++++++ Middleware/ApiSecurityHeadersMiddleware.cs | 50 +++ Middleware/LanguageRedirectionMiddleware.cs | 37 +- Models/ApiPlanTier.cs | 36 ++ Models/ApiSubscription.cs | 36 ++ Models/DTOs/QRResponseDto.cs | 22 ++ Models/DTOs/UserRequesterContext.cs | 10 + Models/User.cs | 34 ++ Models/ViewModels/QRGenerationRequest.cs | 7 + Program.cs | 122 +++++- Properties/launchSettings.json | 2 +- QRRapidoApp.csproj | 12 + QRRapidoApp.sln | 6 + Services/ApiRateLimitService.cs | 88 +++++ Services/HealthChecks/SeqHealthCheck.cs | 126 ------ Services/IApiRateLimitService.cs | 23 ++ Services/IMarkdownService.cs | 4 +- Services/IQRBusinessManager.cs | 10 + Services/IQuotaValidator.cs | 10 + Services/IUserService.cs | 10 + Services/MarkdownService.cs | 12 +- Services/QRBusinessManager.cs | 106 +++++ Services/QRRapidoService.cs | 27 +- Services/QuotaValidator.cs | 72 ++++ Services/StripeService.cs | 225 ++++++++++- Services/UserService.cs | 138 +++++++ Views/DevTutoriais/Article.cshtml | 102 +++++ Views/DevTutoriais/Index.cshtml | 64 +++ Views/Developer/Index.cshtml | 367 ++++++++++++++++++ Views/Developer/Pricing.cshtml | 180 +++++++++ Views/Shared/_Layout.cshtml | 3 + Views/_ViewImports.cshtml | 4 + appsettings.json | 4 - 48 files changed, 3362 insertions(+), 316 deletions(-) create mode 100644 .dockerignore create mode 100644 Content/DevTutoriais/formatos-de-imagem.es-PY.md create mode 100644 Content/DevTutoriais/formatos-de-imagem.pt-BR.md create mode 100644 Content/DevTutoriais/gerando-qrcodes-pela-api.es-PY.md create mode 100644 Content/DevTutoriais/gerando-qrcodes-pela-api.pt-BR.md create mode 100644 Content/DevTutoriais/qr-code-pix-estatico.es-PY.md create mode 100644 Content/DevTutoriais/qr-code-pix-estatico.pt-BR.md create mode 100644 Controllers/DevTutoriaisController.cs create mode 100644 Controllers/DeveloperController.cs create mode 100644 Controllers/QRManagerController.cs create mode 100644 Filters/ApiKeyAuthorizeAttribute.cs create mode 100644 Middleware/ApiSecurityHeadersMiddleware.cs create mode 100644 Models/ApiPlanTier.cs create mode 100644 Models/ApiSubscription.cs create mode 100644 Models/DTOs/QRResponseDto.cs create mode 100644 Models/DTOs/UserRequesterContext.cs create mode 100644 Services/ApiRateLimitService.cs delete mode 100644 Services/HealthChecks/SeqHealthCheck.cs create mode 100644 Services/IApiRateLimitService.cs create mode 100644 Services/IQRBusinessManager.cs create mode 100644 Services/IQuotaValidator.cs create mode 100644 Services/QRBusinessManager.cs create mode 100644 Services/QuotaValidator.cs create mode 100644 Views/DevTutoriais/Article.cshtml create mode 100644 Views/DevTutoriais/Index.cshtml create mode 100644 Views/Developer/Index.cshtml create mode 100644 Views/Developer/Pricing.cshtml create mode 100644 Views/_ViewImports.cshtml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..885fe8d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Build output +bin/ +obj/ +out/ + +# IDE +.vs/ +.vscode/ +.idea/ +*.user +*.suo + +# Node +node_modules/ +wwwroot/dist/ + +# Tests +Tests.E2E/ +Tests/ +coverage*/ +TestResults/ + +# Git +.git/ +.gitignore +.github/ +.gitea/ + +# Dev configs (secrets never go in image) +appsettings.Local.json +appsettings.Production.json +*.Development.json +appsettings.Staging.json +secrets.env +scripts/secrets.env +keys/ +.env* + +# Logs / temp +logs/ +uploads/ +temp/ +*.log + +# Docs +*.md +!Content/ + +# Playwright +Tests.E2E/auth-state.json +Tests.E2E/last-api-key.txt + +# macOS +.DS_Store diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7e43455..d4c98a9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -188,6 +188,9 @@ jobs: --env-add Serilog__OpenSearchUrl="http://141.148.162.114:19201" \ --env-add Serilog__OpenSearchFallback="http://129.146.116.218:19202" \ --env-add Admin__AllowedEmails__0="rrcgoncalves@gmail.com" \ + --update-order start-first \ + --update-delay 30s \ + --update-parallelism 1 \ --with-registry-auth \ qrrapido-prod else @@ -241,10 +244,10 @@ jobs: # Verifica se os serviços estão respondendo em ambos servidores echo "Verificando Servidor 1..." - ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 'curl -f http://localhost:5001/health || echo "⚠️ Servidor 1 pode não estar respondendo"' + ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 'curl -f http://localhost:5001/healthcheck || echo "⚠️ Servidor 1 pode não estar respondendo"' echo "Verificando Servidor 2..." - ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 'curl -f http://localhost:5001/health || echo "⚠️ Servidor 2 pode não estar respondendo"' + ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 'curl -f http://localhost:5001/healthcheck || echo "⚠️ Servidor 2 pode não estar respondendo"' # Testa o site principal echo "Testando site principal..." diff --git a/.gitignore b/.gitignore index 1674e3b..4459fd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Playwright E2E — sessão salva e chaves temporárias (contêm cookies/secrets) +Tests.E2E/auth-state.json +Tests.E2E/last-api-key.txt + ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## @@ -384,3 +388,4 @@ scripts/secrets.env *.crt *.key wwwroot/dist/ +.aider* diff --git a/Content/DevTutoriais/formatos-de-imagem.es-PY.md b/Content/DevTutoriais/formatos-de-imagem.es-PY.md new file mode 100644 index 0000000..b885140 --- /dev/null +++ b/Content/DevTutoriais/formatos-de-imagem.es-PY.md @@ -0,0 +1,129 @@ +--- +title: "PNG, WebP o SVG: Eligiendo el Formato Correcto para tu Código QR" +description: "Comparativa técnica entre PNG, WebP y SVG para códigos QR vía API. Sabé cuál formato usar según tu caso de uso: e-mail, impresión, web o apps." +keywords: "qr code png, qr code webp, qr code svg, formato imagen qr code, api qr code formato paraguay" +author: "QRRapido" +date: 2026-03-08 +lastmod: 2026-03-08 +image: "" +--- + +# PNG, WebP o SVG: Eligiendo el Formato Correcto para tu Código QR + +La API QRRapido soporta tres formatos de salida: `png`, `webp` ha `svg`. Cada uno tiene características distintas que impactan el tamaño del archivo, la calidad ha la compatibilidad. Usá el parámetro `outputFormat` en la solicitud para elegir. + +```json +{ + "content": "https://tuempresa.com.py", + "type": "url", + "outputFormat": "webp" +} +``` + +--- + +## Resumen Rápido + +| Formato | Tamaño | Escala sin pérdida | Compatibilidad | Ideal para | +|---|---|---|---|---| +| **PNG** | Medio | No (raster) | Universal | Impresión, e-mail, sistemas legados | +| **WebP** | Pequeño (~30% menor) | No (raster) | Browsers modernos | Web, apps, APIs | +| **SVG** | Variable (generalmente menor) | **Sí** | Browsers, Adobe, Figma | Logos, banners, impresión vectorial | + +--- + +## PNG — El Estándar Universal + +PNG es un formato raster sin pérdida, ideal para códigos QR porque preserva 100% de los píxeles blancos ha negros sin introducir artefactos. + +**Cuándo usarlo:** +- Envío por e-mail (clientes de correo antiguos no soportan WebP) +- Integración con sistemas legados +- Cuando la compatibilidad máxima es prioridad + +**¿Por qué no JPEG?** + +> JPEG usa compresión con pérdida que introduce artefactos de borrosidad en los bordes de los módulos del código QR. Eso puede volverlo ilegible, especialmente en tamaños pequeños. **Nunca usés JPEG para códigos QR.** + +```json +{ "outputFormat": "png" } +``` + +--- + +## WebP — Recomendado para Web ha Apps + +WebP es el formato porã para la mayoría de las integraciones modernas. La calidad visual es indistinguible del PNG para códigos QR, con **25–35% menos tamaño de archivo**. + +**Ventajas:** +- Carga más rápida en páginas web +- Menor uso de ancho de banda en APIs con alto volumen +- Soporte nativo en todos los browsers modernos (Chrome, Safari 14+, Firefox, Edge) +- Menor consumo de almacenamiento en la nube (S3, GCS, etc.) + +**Cuándo usarlo:** +- Visualización en `` en sitios ha aplicaciones web +- Apps iOS/Android (ambos soportan WebP) +- Cuando almacenás los QR generados + +```json +{ "outputFormat": "webp" } +``` + +Para mostrar en HTML: +```html + + + Código QR + +``` + +--- + +## SVG — Para Escalar sin Pérdida + +SVG es un formato vectorial: el código QR se describe matemáticamente, no como píxeles. Eso significa que se puede redimensionar a cualquier tamaño — desde un ícono de 16px hasta un banner de 3 metros — sin perder calidad. + +**Cuándo usarlo:** +- Material gráfico (banners, carteles, packaging, remeras) +- Integración con herramientas de diseño (Figma, Illustrator, Canva) +- Impresión de alta calidad sin depender de la resolución + +**Atención:** +- No usés SVG para envío por e-mail (la mayoría de los clientes bloquea SVG por seguridad) +- Algunos lectores de QR antiguos pueden tener dificultad con SVG renderizado directamente vía `` + +```json +{ "outputFormat": "svg" } +``` + +Para mostrar SVG inline, el `qrCodeBase64` debe decodificarse primero: +```js +const svgString = atob(qrCodeBase64); +document.getElementById('qr').innerHTML = svgString; +``` + +--- + +## Comparativa de Tamaño (ejemplo real — 400px, URL simple) + +| Formato | Tamaño típico | +|---|---| +| PNG | ~8–12 KB | +| WebP | ~5–8 KB | +| SVG | ~3–6 KB | + +> Los tamaños varían según la complejidad del contenido (los QR con más datos tienen más módulos). + +--- + +## Recomendación por Caso de Uso + +| Escenario | Formato recomendado | +|---|---| +| Mostrar en sitio/app | **WebP** | +| Enviar por e-mail | **PNG** | +| Impresión gráfica / diseño | **SVG** | +| Guardar en la nube | **WebP** | +| Máxima compatibilidad | **PNG** | +| Sin preocupación por tamaño | **PNG** | diff --git a/Content/DevTutoriais/formatos-de-imagem.pt-BR.md b/Content/DevTutoriais/formatos-de-imagem.pt-BR.md new file mode 100644 index 0000000..791b95a --- /dev/null +++ b/Content/DevTutoriais/formatos-de-imagem.pt-BR.md @@ -0,0 +1,129 @@ +--- +title: "PNG, WebP ou SVG? Escolhendo o Formato Certo para seu QR Code" +description: "Comparativo técnico entre PNG, WebP e SVG para QR codes via API. Saiba qual formato usar dependendo do seu caso de uso: e-mail, impressão, web ou apps." +keywords: "qr code png, qr code webp, qr code svg, formato imagem qr code, api qr code formato" +author: "QRRapido" +date: 2026-03-08 +lastmod: 2026-03-08 +image: "" +--- + +# PNG, WebP ou SVG? Escolhendo o Formato Certo para seu QR Code + +A API QRRapido suporta três formatos de saída: `png`, `webp` e `svg`. Cada um tem características distintas que impactam tamanho de arquivo, qualidade e compatibilidade. Use o parâmetro `outputFormat` na requisição para escolher. + +```json +{ + "content": "https://seusite.com", + "type": "url", + "outputFormat": "webp" +} +``` + +--- + +## Resumo Rápido + +| Formato | Tamanho | Escala sem perda | Compatibilidade | Ideal para | +|---|---|---|---|---| +| **PNG** | Médio | Não (raster) | Universal | Impressão, e-mail, legado | +| **WebP** | Pequeno (~30% menor) | Não (raster) | Browsers modernos | Web, apps, APIs | +| **SVG** | Variável (geralmente menor) | **Sim** | Browsers, Adobe, Figma | Logos, banners, impressão vetorial | + +--- + +## PNG — O Padrão Universal + +PNG é um formato raster sem perda, ideal para QR codes porque preserva 100% dos pixels pretos e brancos sem introduzir artefatos. + +**Quando usar:** +- Envio por e-mail (clientes de e-mail antigos não suportam WebP) +- Integração com sistemas legados +- Quando a compatibilidade máxima é prioridade + +**Por que não JPEG?** + +> JPEG usa compressão com perda que introduz artefatos de borrão nas bordas dos módulos do QR code. Isso pode tornar o código ilegível, especialmente em tamanhos menores. **Nunca use JPEG para QR codes.** + +```json +{ "outputFormat": "png" } +``` + +--- + +## WebP — Recomendado para Web e Apps + +WebP é o formato ideal para a maioria das integrações modernas. A qualidade visual é indistinguível do PNG para QR codes, com **25–35% menos tamanho de arquivo**. + +**Vantagens:** +- Carregamento mais rápido em páginas web +- Menor uso de bandwidth em APIs com alto volume +- Suporte nativo em todos os browsers modernos (Chrome, Safari 14+, Firefox, Edge) +- Menor consumo de armazenamento em buckets (S3, GCS, etc.) + +**Quando usar:** +- Exibição em `` em sites e aplicações web +- Apps iOS/Android (ambos suportam WebP) +- Quando você armazena os QR codes gerados + +```json +{ "outputFormat": "webp" } +``` + +Para exibir no HTML: +```html + + + QR Code + +``` + +--- + +## SVG — Para Escalar sem Perda + +SVG é um formato vetorial: o QR code é descrito matematicamente, não como pixels. Isso significa que pode ser redimensionado para qualquer tamanho — de um ícone de 16px a um banner de 3 metros — sem perda de qualidade. + +**Quando usar:** +- Material gráfico (banners, cartazes, embalagens, camisas) +- Integração com ferramentas de design (Figma, Illustrator, Canva) +- Impressão de alta qualidade sem depender de resolução + +**Atenção:** +- Não use SVG para envio por e-mail (a maioria dos clientes bloqueia SVG por segurança) +- Alguns leitores de QR antigos podem ter dificuldade com SVG renderizado diretamente via `` + +```json +{ "outputFormat": "svg" } +``` + +Para exibir SVG inline, o `qrCodeBase64` deve ser decodificado primeiro: +```js +const svgString = atob(qrCodeBase64); +document.getElementById('qr').innerHTML = svgString; +``` + +--- + +## Comparativo de Tamanho (exemplo real — 400px, URL simples) + +| Formato | Tamanho típico | +|---|---| +| PNG | ~8–12 KB | +| WebP | ~5–8 KB | +| SVG | ~3–6 KB | + +> Os tamanhos variam conforme a complexidade do conteúdo (QR codes com mais dados têm mais módulos). + +--- + +## Recomendação por Caso de Uso + +| Cenário | Formato recomendado | +|---|---| +| Exibir em site/app | **WebP** | +| Enviar por e-mail | **PNG** | +| Impressão gráfica / design | **SVG** | +| Armazenar em cloud | **WebP** | +| Máxima compatibilidade | **PNG** | +| Sem preocupação com tamanho | **PNG** | diff --git a/Content/DevTutoriais/gerando-qrcodes-pela-api.es-PY.md b/Content/DevTutoriais/gerando-qrcodes-pela-api.es-PY.md new file mode 100644 index 0000000..e58f975 --- /dev/null +++ b/Content/DevTutoriais/gerando-qrcodes-pela-api.es-PY.md @@ -0,0 +1,186 @@ +--- +title: "Cómo Generar Códigos QR por la API: Todos los Tipos" +description: "Guía completa para generar códigos QR vía API REST usando cada tipo disponible: URL, Pix, Wi-Fi, vCard, WhatsApp, SMS, e-mail y texto libre." +keywords: "api qr code, generar qr code api, qr code rest api, qrrapido api, tipos qr code paraguay" +author: "QRRapido" +date: 2026-03-08 +lastmod: 2026-03-08 +image: "" +--- + +# Cómo Generar Códigos QR por la API: Todos los Tipos + +La API QRRapido soporta 8 tipos de código QR. Todos usan el mismo endpoint — lo que cambia es el campo `type` ha los campos específicos que forman el `content`. + +## Endpoint + +``` +POST /api/v1/QRManager/generate +X-API-Key: tu_clave_aqui +Content-Type: application/json +``` + +## Estructura Base de la Solicitud + +```json +{ + "content": "...", + "type": "url", + "size": 400, + "primaryColor": "#000000", + "backgroundColor": "#FFFFFF", + "outputFormat": "png" +} +``` + +| Campo | Tipo | Por defecto | Descripción | +|---|---|---|---| +| `content` | string | obligatorio | Contenido del QR | +| `type` | string | `"url"` | Tipo de QR (ver abajo) | +| `size` | int | `300` | Tamaño en píxeles (100–2000) | +| `primaryColor` | string | `"#000000"` | Color de los módulos (hex) | +| `backgroundColor` | string | `"#FFFFFF"` | Color de fondo (hex) | +| `outputFormat` | string | `"png"` | Formato: `png`, `webp`, `svg` | + +--- + +## Tipo `url` — Link / URL + +El más simple: cualquier URL válida. + +```json +{ + "type": "url", + "content": "https://tuempresa.com.py/producto/123" +} +``` + +> Usá `https://` siempre que sea posible. Los QR con HTTP pueden ser bloqueados por algunos lectores modernos. + +--- + +## Tipo `pix` — Pago Pix + +El `content` debe ser la **clave Pix** del receptor. La generación produce un QR de Pix estático (sin conexión con el Banco Central — mirá el tutorial dedicado sobre Pix). + +```json +{ + "type": "pix", + "content": "contacto@tuempresa.com.br" +} +``` + +Claves aceptadas: CPF/CNPJ (solo dígitos), e-mail, teléfono en formato `+5511999999999`, o clave aleatoria (UUID). + +--- + +## Tipo `wifi` — Red Wi-Fi + +El `content` usa el formato estándar `WIFI:`: + +```json +{ + "type": "wifi", + "content": "WIFI:S:NombreDeRed;T:WPA;P:ContraseñaDeRed;;" +} +``` + +| Parámetro | Significado | Valores | +|---|---|---| +| `S:` | SSID (nombre de la red) | texto | +| `T:` | Tipo de seguridad | `WPA`, `WEP`, `nopass` | +| `P:` | Contraseña | texto | +| `H:` | Red oculta (opcional) | `true` / `false` | + +--- + +## Tipo `vcard` — Tarjeta de Visita + +El `content` debe ser un vCard v3 completo: + +```json +{ + "type": "vcard", + "content": "BEGIN:VCARD\nVERSION:3.0\nFN:Juan Pérez\nORG:Empresa SRL\nTEL:+595981999999\nEMAIL:juan@empresa.com.py\nURL:https://empresa.com.py\nEND:VCARD" +} +``` + +--- + +## Tipo `whatsapp` — Link para WhatsApp + +```json +{ + "type": "whatsapp", + "content": "https://wa.me/595981999999?text=Mba'éichapa%2C%20quiero%20más%20información" +} +``` + +El número debe incluir código de país ha área, sin espacios ni símbolos. El parámetro `text` es opcional. + +--- + +## Tipo `email` — E-mail con asunto ha cuerpo + +```json +{ + "type": "email", + "content": "mailto:contacto@empresa.com.py?subject=Consulta&body=Mba'éichapa%2C%20quisiera%20saber%20más" +} +``` + +--- + +## Tipo `sms` — SMS pre-cargado + +```json +{ + "type": "sms", + "content": "sms:+595981999999?body=Mba'éichapa%2C%20quiero%20más%20información" +} +``` + +--- + +## Tipo `texto` — Texto Libre + +Cualquier string de hasta 2048 caracteres: + +```json +{ + "type": "texto", + "content": "Mesa 5 — Atención preferencial disponible en el mostrador central. Porã roipytyvõ!" +} +``` + +--- + +## Respuesta Exitosa + +```json +{ + "success": true, + "qrCodeBase64": "iVBORw0KGgo...", + "qrId": "abc123", + "generationTimeMs": 180, + "remainingCredits": 42, + "fromCache": false, + "format": "png", + "mimeType": "image/png" +} +``` + +Para mostrar la imagen directamente en HTML: + +```html +Código QR +``` + +--- + +## Consejos Generales + +- **Colores**: mantené alto contraste entre `primaryColor` ha `backgroundColor`. Evitá amarillo sobre blanco o gris claro sobre blanco. +- **Tamaño mínimo impreso**: usá `size` ≥ 400 para impresiones. Para uso digital, 300 es suficiente. +- **Caché**: solicitudes idénticas devuelven `fromCache: true` sin consumir crédito adicional. +- **Rate limit**: los headers `X-RateLimit-Remaining` ha `X-Quota-Remaining` en la respuesta indican tu saldo actual. diff --git a/Content/DevTutoriais/gerando-qrcodes-pela-api.pt-BR.md b/Content/DevTutoriais/gerando-qrcodes-pela-api.pt-BR.md new file mode 100644 index 0000000..86c765b --- /dev/null +++ b/Content/DevTutoriais/gerando-qrcodes-pela-api.pt-BR.md @@ -0,0 +1,186 @@ +--- +title: "Como Gerar QR Codes pela API: Todos os Tipos Suportados" +description: "Guia completo para gerar QR codes via API REST usando cada tipo disponível: URL, Pix, Wi-Fi, vCard, WhatsApp, SMS, e-mail e texto livre." +keywords: "api qr code, gerar qr code api, qr code rest api, qrrapido api, tipos qr code" +author: "QRRapido" +date: 2026-03-08 +lastmod: 2026-03-08 +image: "" +--- + +# Como Gerar QR Codes pela API: Todos os Tipos Suportados + +A API QRRapido suporta 8 tipos de QR code. Todos usam o mesmo endpoint — o que muda é o campo `type` e os campos específicos que compõem o `content`. + +## Endpoint + +``` +POST /api/v1/QRManager/generate +X-API-Key: sua_chave_aqui +Content-Type: application/json +``` + +## Estrutura Base da Requisição + +```json +{ + "content": "...", + "type": "url", + "size": 400, + "primaryColor": "#000000", + "backgroundColor": "#FFFFFF", + "outputFormat": "png" +} +``` + +| Campo | Tipo | Padrão | Descrição | +|---|---|---|---| +| `content` | string | obrigatório | Conteúdo do QR code | +| `type` | string | `"url"` | Tipo do QR code (ver abaixo) | +| `size` | int | `300` | Tamanho em pixels (100–2000) | +| `primaryColor` | string | `"#000000"` | Cor dos módulos (hex) | +| `backgroundColor` | string | `"#FFFFFF"` | Cor de fundo (hex) | +| `outputFormat` | string | `"png"` | Formato: `png`, `webp`, `svg` | + +--- + +## Tipo `url` — Link / URL + +O mais simples: qualquer URL válida. + +```json +{ + "type": "url", + "content": "https://seusite.com.br/produto/123" +} +``` + +> Use `https://` sempre que possível. QR codes com HTTP podem ser bloqueados por alguns leitores modernos. + +--- + +## Tipo `pix` — Pagamento Pix + +O `content` deve ser a **chave Pix** do recebedor. A geração produz um QR de Pix estático (sem conexão com o Banco Central — veja o tutorial dedicado sobre Pix). + +```json +{ + "type": "pix", + "content": "contato@suaempresa.com.br" +} +``` + +Chaves aceitas: CPF/CNPJ (apenas dígitos), e-mail, telefone no formato `+5511999999999`, ou chave aleatória (UUID). + +--- + +## Tipo `wifi` — Rede Wi-Fi + +O `content` usa o formato padrão `WIFI:`: + +```json +{ + "type": "wifi", + "content": "WIFI:S:NomeDaRede;T:WPA;P:SenhaDaRede;;" +} +``` + +| Parâmetro | Significado | Valores | +|---|---|---| +| `S:` | SSID (nome da rede) | texto | +| `T:` | Tipo de segurança | `WPA`, `WEP`, `nopass` | +| `P:` | Senha | texto | +| `H:` | Rede oculta (opcional) | `true` / `false` | + +--- + +## Tipo `vcard` — Cartão de Visita + +O `content` deve ser um vCard v3 completo: + +```json +{ + "type": "vcard", + "content": "BEGIN:VCARD\nVERSION:3.0\nFN:João Silva\nORG:Empresa Ltda\nTEL:+5511999999999\nEMAIL:joao@empresa.com\nURL:https://empresa.com\nEND:VCARD" +} +``` + +--- + +## Tipo `whatsapp` — Link para WhatsApp + +```json +{ + "type": "whatsapp", + "content": "https://wa.me/5511999999999?text=Olá%2C%20gostaria%20de%20saber%20mais" +} +``` + +O número deve incluir código do país e DDD, sem espaços ou símbolos. O parâmetro `text` é opcional. + +--- + +## Tipo `email` — E-mail com assunto e corpo + +```json +{ + "type": "email", + "content": "mailto:contato@empresa.com?subject=Assunto&body=Mensagem%20inicial" +} +``` + +--- + +## Tipo `sms` — SMS pré-preenchido + +```json +{ + "type": "sms", + "content": "sms:+5511999999999?body=Olá%2C%20quero%20mais%20informações" +} +``` + +--- + +## Tipo `texto` — Texto Livre + +Qualquer string de até 2048 caracteres: + +```json +{ + "type": "texto", + "content": "Mesa 12 — Atendimento preferencial disponível no balcão central." +} +``` + +--- + +## Resposta de Sucesso + +```json +{ + "success": true, + "qrCodeBase64": "iVBORw0KGgo...", + "qrId": "abc123", + "generationTimeMs": 180, + "remainingCredits": 42, + "fromCache": false, + "format": "png", + "mimeType": "image/png" +} +``` + +Para exibir a imagem diretamente no HTML: + +```html +QR Code +``` + +--- + +## Dicas Gerais + +- **Cores**: mantenha alto contraste entre `primaryColor` e `backgroundColor`. Evite amarelo sobre branco ou cinza claro sobre branco. +- **Tamanho mínimo impresso**: use `size` ≥ 400 para impressões. Para uso digital (telas), 300 é suficiente. +- **Cache**: requisições idênticas retornam `fromCache: true` sem consumir crédito adicional. +- **Rate limit**: os headers `X-RateLimit-Remaining` e `X-Quota-Remaining` na resposta indicam seu saldo atual. diff --git a/Content/DevTutoriais/qr-code-pix-estatico.es-PY.md b/Content/DevTutoriais/qr-code-pix-estatico.es-PY.md new file mode 100644 index 0000000..90da9b9 --- /dev/null +++ b/Content/DevTutoriais/qr-code-pix-estatico.es-PY.md @@ -0,0 +1,142 @@ +--- +title: "Código QR Pix Estático: Cómo Funciona y Responsabilidades del Desarrollador" +description: "Entendé qué es el QR Pix estático, cómo lo genera la API, sus limitaciones ha por qué la verificación del pago es responsabilidad de tu aplicación — no de QRRapido." +keywords: "qr code pix, pix estatico, qr code pix api, integración pix qr code paraguay, pix sin bacen" +author: "QRRapido" +date: 2026-03-08 +lastmod: 2026-03-08 +image: "" +--- + +# Código QR Pix Estático: Cómo Funciona y Responsabilidades del Desarrollador + +## ¿Qué es un Código QR Pix Estático? + +El Pix tiene dos tipos de QR: **estático** ha **dinámico**. + +| | Pix Estático | Pix Dinámico | +|---|---|---| +| Valor | Variable (lo decide el pagador) | Fijo (definido por el receptor) | +| Generación | Cualquier sistema | Intermediario PSP/BACEN | +| Registro en BACEN | **No** | Sí | +| Notificación de pago | **No** | Sí (webhook) | +| Uso típico | Donaciones, cobros simples | E-commerce, cobro con control | + +La API QRRapido genera **exclusivamente QR Pix estático**. Eso es intencional ha suficiente para la mayoría de los casos de uso. + +--- + +## Cómo la API Genera el QR Pix + +Al enviar una solicitud con `type: "pix"`, la API arma un string en el estándar **EMV® QR Code** (estándar internacional adoptado por el Banco Central de Brasil) ha genera la imagen: + +```json +{ + "type": "pix", + "content": "contacto@tuempresa.com.br" +} +``` + +El campo `content` debe contener **solo la clave Pix** del receptor. La API se encarga de armar el payload EMV correctamente. + +**Claves aceptadas:** +- E-mail: `contacto@empresa.com.br` +- CPF/CNPJ: solo dígitos — `12345678901` o `12345678000195` +- Teléfono: `+5511999999999` +- Clave aleatoria (EVP): `123e4567-e89b-12d3-a456-426614174000` + +--- + +## Lo que la API NO hace (ha por qué eso importa) + +> **QRRapido no tiene integración con el Banco Central, con ningún banco ni PSP (Proveedor de Servicio de Pago).** + +Eso significa: + +1. **La API no sabe si el pago fue realizado.** Solo genera la imagen del QR. Lo que pasa después — si el cliente escaneó, si el Pix fue enviado, si llegó a la cuenta correcta — está fuera del alcance de la API. + +2. **No hay webhook de confirmación de pago.** La API no envía notificaciones cuando se recibe un Pix. + +3. **El QR no expira automáticamente.** Un QR Pix estático es válido indefinidamente (o hasta que la clave Pix sea eliminada por el receptor en su banco). + +4. **No hay rastreabilidad de transacción.** Dos solicitudes con la misma clave generan el mismo QR (con caché). No es posible asociar un escaneo a una transacción específica vía API. + +--- + +## Responsabilidad de Tu Aplicación + +Si usás la API para generar QR Pix en un flujo de pago, **es responsabilidad de tu aplicación verificar el cobro**. Las formas correctas de hacerlo son: + +### Opción 1 — API Pix del Banco del Receptor +Conectate directamente a la API Pix del banco donde está registrada la clave. La mayoría de los bancos ofrece: +- Consulta de cobros recibidos +- Webhook de notificación en tiempo real (cuando el banco lo soporta) + +### Opción 2 — PSP / Gateway de Pago +Usá un intermediario como Mercado Pago, PagSeguro, Efí Bank, Asaas, etc. Ofrecen Pix dinámico con control completo: valor fijo, expiración, webhooks e identificación única por cobro. + +### Opción 3 — Verificación Manual +Para volúmenes bajos o contextos informales (donaciones, ventas presenciales), el responsable del cobro verifica el extracto bancario manualmente. + +--- + +## Flujo Recomendado (con confirmación) + +``` +Tu App API QRRapido Banco del Receptor + | | | + |-- POST /generate (pix) -->| | + |<-- qrCodeBase64 ----------| | + | | | + |-- Muestra QR al cliente | | + | | | + |-- Consulta pago ---------------------------------->| + |<-- Estado recibido o no ----------------------------| + | | | + |-- Libera producto/servicio| | +``` + +--- + +## Ejemplo de Solicitud Completa + +```bash +curl -X POST https://qrrapido.site/api/v1/QRManager/generate \ + -H "X-API-Key: tu_clave_aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "content": "contacto@tuempresa.com.br", + "type": "pix", + "size": 400, + "outputFormat": "webp" + }' +``` + +--- + +## Casos de Uso Adecuados para Pix Estático vía API + +- QR de donación en sitio institucional +- Menú digital con clave para pago presencial +- Generación de QR en lote para materiales impresos (flyers, tarjetas) +- Aplicaciones donde el vendedor ha comprador interactúan presencialmente ha el vendedor confirma el cobro en la app del banco + +## Casos de Uso que Requieren Pix Dinámico (no cubiertos por esta API) + +- E-commerce con confirmación automática del pedido +- Cobro con valor fijo ha expiración +- Emisión de factura vinculada al pago +- Conciliación financiera automatizada + +--- + +## Resumen + +| Lo que la API hace | Lo que la API NO hace | +|---|---| +| Genera la imagen del QR Pix | Verifica si el pago fue hecho | +| Formatea el payload EMV correctamente | Envía webhook de confirmación | +| Entrega PNG, WebP o SVG | Se comunica con el BACEN o bancos | +| Funciona con cualquier clave Pix válida | Garantiza que la clave pertenece a quien dice | + +La generación correcta del QR es responsabilidad de la API. La **confirmación del pago es responsabilidad de tu aplicación** — ha eso, ñande lo tenemos claro ko'aga. diff --git a/Content/DevTutoriais/qr-code-pix-estatico.pt-BR.md b/Content/DevTutoriais/qr-code-pix-estatico.pt-BR.md new file mode 100644 index 0000000..2c608ce --- /dev/null +++ b/Content/DevTutoriais/qr-code-pix-estatico.pt-BR.md @@ -0,0 +1,142 @@ +--- +title: "QR Code Pix Estático: Como Funciona e Responsabilidades do Desenvolvedor" +description: "Entenda o que é o QR Code Pix estático, como ele é gerado pela API, suas limitações e por que a verificação do pagamento é responsabilidade da sua aplicação — não da QRRapido." +keywords: "qr code pix, pix estatico, qr code pix api, integração pix qr code, pix sem bacen" +author: "QRRapido" +date: 2026-03-08 +lastmod: 2026-03-08 +image: "" +--- + +# QR Code Pix Estático: Como Funciona e Responsabilidades do Desenvolvedor + +## O que é um QR Code Pix Estático? + +O Pix tem dois tipos de QR code: **estático** e **dinâmico**. + +| | Pix Estático | Pix Dinâmico | +|---|---|---| +| Valor | Variável (pagador decide) | Fixo (definido pelo recebedor) | +| Geração | Qualquer sistema | Intermediário PSP/BACEN | +| Registro no BACEN | **Não** | Sim | +| Notificação de pagamento | **Não** | Sim (webhook) | +| Uso típico | Doações, cobranças simples | E-commerce, cobrança com controle | + +A API QRRapido gera **exclusivamente QR code Pix estático**. Isso é deliberado e suficiente para a maioria dos casos de uso. + +--- + +## Como a API Gera o QR Code Pix + +Ao enviar uma requisição com `type: "pix"`, a API monta uma string no padrão **EMV® QR Code** (padrão internacional adotado pelo Banco Central do Brasil) e gera a imagem: + +```json +{ + "type": "pix", + "content": "contato@suaempresa.com.br" +} +``` + +O campo `content` deve conter **apenas a chave Pix** do recebedor. A API cuida de montar o payload EMV corretamente. + +**Chaves aceitas:** +- E-mail: `contato@empresa.com.br` +- CPF/CNPJ: apenas dígitos — `12345678901` ou `12345678000195` +- Telefone: `+5511999999999` +- Chave aleatória (EVP): `123e4567-e89b-12d3-a456-426614174000` + +--- + +## O que a API NÃO faz (e por quê isso importa) + +> **A QRRapido não tem integração com o Banco Central, com nenhum banco ou PSP (Provedor de Serviço de Pagamento).** + +Isso significa: + +1. **A API não sabe se o pagamento foi realizado.** Ela apenas gera a imagem do QR code. O que acontece depois — se o cliente escaneou, se o Pix foi enviado, se caiu na conta certa — está fora do escopo da API. + +2. **Não há webhook de confirmação de pagamento.** A API não envia notificações quando um Pix é recebido. + +3. **O QR code não expira automaticamente.** Um QR code Pix estático é válido indefinidamente (ou até a chave Pix ser removida pelo recebedor no banco). + +4. **Não há rastreabilidade de transação.** Duas requisições com a mesma chave geram o mesmo QR code (com cache). Não é possível associar um escaneamento a uma transação específica via API. + +--- + +## Responsabilidade da Sua Aplicação + +Se você usa a API para gerar QR codes Pix em um fluxo de pagamento, **é responsabilidade da sua aplicação verificar o recebimento**. As formas corretas de fazer isso são: + +### Opção 1 — API Pix do Banco do Recebedor +Conecte-se diretamente à API Pix do banco onde está registrada a chave. A maioria dos bancos oferece: +- Consulta de cobranças recebidas +- Webhook de notificação em tempo real (quando o banco suporta) + +### Opção 2 — PSP / Gateway de Pagamento +Use um intermediário como Mercado Pago, PagSeguro, Efí Bank, Asaas, etc. Eles oferecem Pix dinâmico com controle completo: valor fixo, expiração, webhooks e identificação única por cobrança. + +### Opção 3 — Conferência Manual +Para volumes baixos ou contextos informais (doações, vendas presenciais), o responsável pelo recebimento verifica o extrato bancário manualmente. + +--- + +## Fluxo Recomendado (com confirmação) + +``` +Sua App API QRRapido Banco do Recebedor + | | | + |-- POST /generate (pix) -->| | + |<-- qrCodeBase64 ----------| | + | | | + |-- Exibe QR para cliente | | + | | | + |-- Consulta pagamento ------------------------------>| + |<-- Status recebido ou não --------------------------| + | | | + |-- Libera produto/serviço | | +``` + +--- + +## Exemplo de Requisição Completa + +```bash +curl -X POST https://qrrapido.site/api/v1/QRManager/generate \ + -H "X-API-Key: sua_chave_aqui" \ + -H "Content-Type: application/json" \ + -d '{ + "content": "contato@suaempresa.com.br", + "type": "pix", + "size": 400, + "outputFormat": "webp" + }' +``` + +--- + +## Casos de Uso Adequados para Pix Estático via API + +- QR code de doação em site institucional +- Cardápio digital com chave para pagamento presencial +- Geração de QR em lote para materiais impressos (flyers, cartões) +- Aplicativos onde o vendedor e comprador interagem pessoalmente e o vendedor confirma o recebimento no app do banco + +## Casos de Uso que Requerem Pix Dinâmico (não atendidos por esta API) + +- E-commerce com confirmação automática do pedido +- Cobrança com valor fixo e expiração +- Emissão de nota fiscal vinculada ao pagamento +- Conciliação financeira automatizada + +--- + +## Resumo + +| O que a API faz | O que a API NÃO faz | +|---|---| +| Gera a imagem do QR code Pix | Verifica se o pagamento foi feito | +| Formata o payload EMV corretamente | Envia webhook de confirmação | +| Entrega PNG, WebP ou SVG | Se comunica com o BACEN ou bancos | +| Funciona com qualquer chave Pix válida | Garante que a chave pertence a quem diz pertencer | + +A geração correta do QR code é responsabilidade da API. A **confirmação do pagamento é responsabilidade da sua aplicação**. diff --git a/Controllers/DevTutoriaisController.cs b/Controllers/DevTutoriaisController.cs new file mode 100644 index 0000000..23d7592 --- /dev/null +++ b/Controllers/DevTutoriaisController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using QRRapidoApp.Services; + +namespace QRRapidoApp.Controllers +{ + public class DevTutoriaisController : Controller + { + private readonly IMarkdownService _markdownService; + private readonly ILogger _logger; + private const string ContentFolder = "DevTutoriais"; + + public DevTutoriaisController(IMarkdownService markdownService, ILogger logger) + { + _markdownService = markdownService; + _logger = logger; + } + + [Route("Developer/docs")] + [Route("es-PY/Developer/docs")] + [Route("es/Developer/docs")] + public async Task Index() + { + var culture = GetCulture(); + var articles = await _markdownService.GetAllArticlesAsync(culture, ContentFolder); + ViewBag.Culture = culture; + return View(articles); + } + + [Route("Developer/docs/{slug}")] + [Route("es-PY/Developer/docs/{slug}")] + [Route("es/Developer/docs/{slug}")] + public async Task Article(string slug) + { + var culture = GetCulture(); + var article = await _markdownService.GetArticleAsync(slug, culture, ContentFolder); + + if (article == null) + { + _logger.LogWarning("Dev article not found: {Slug} ({Culture})", slug, culture); + return NotFound(); + } + + var allArticles = await _markdownService.GetAllArticlesAsync(culture, ContentFolder); + article.RelatedArticles = allArticles + .Where(a => a.Slug != slug) + .Take(3) + .ToList(); + + ViewBag.Culture = culture; + ViewBag.Slug = slug; + return View(article); + } + + private string GetCulture() + { + var path = Request.Path.Value ?? ""; + if (path.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase)) return "es-PY"; + if (path.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) || + string.Equals(path, "/es", StringComparison.OrdinalIgnoreCase)) return "es"; + return "pt-BR"; + } + } +} diff --git a/Controllers/DeveloperController.cs b/Controllers/DeveloperController.cs new file mode 100644 index 0000000..4c013e4 --- /dev/null +++ b/Controllers/DeveloperController.cs @@ -0,0 +1,148 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QRRapidoApp.Models; +using QRRapidoApp.Services; +using System.Security.Claims; + +namespace QRRapidoApp.Controllers +{ + [Authorize] + public class DeveloperController : Controller + { + private readonly IUserService _userService; + private readonly StripeService _stripeService; + private readonly ILogger _logger; + + public DeveloperController( + IUserService userService, + StripeService stripeService, + ILogger logger) + { + _userService = userService; + _stripeService = stripeService; + _logger = logger; + } + + [HttpGet] + public async Task Index() + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) return Unauthorized(); + + var user = await _userService.GetUserAsync(userId); + if (user == null) return NotFound(); + + ViewBag.Culture = GetCulture(); + return View(user); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task GenerateKey(string keyName) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) return Unauthorized(); + + if (string.IsNullOrWhiteSpace(keyName) || keyName.Length > 50) + { + TempData["Error"] = "Nome da chave inválido (máx. 50 caracteres)."; + return RedirectToAction(nameof(Index)); + } + + var user = await _userService.GetUserAsync(userId); + if (user == null) return NotFound(); + + if (user.ApiKeys.Count(k => k.IsActive) >= 5) + { + TempData["Error"] = "Limite de 5 chaves ativas atingido. Revogue uma antes de criar outra."; + return RedirectToAction(nameof(Index)); + } + + var (rawKey, prefix) = await _userService.GenerateApiKeyAsync(userId, keyName.Trim()); + + _logger.LogInformation("API key '{Prefix}' generated for user {UserId}", prefix, userId); + + TempData["NewKey"] = rawKey; + TempData["NewKeyName"] = keyName.Trim(); + return RedirectToAction(nameof(Index)); + } + + [HttpGet] + public async Task Pricing() + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + User? user = null; + if (!string.IsNullOrEmpty(userId)) + user = await _userService.GetUserAsync(userId); + + ViewBag.CurrentTier = user?.ApiSubscription?.EffectiveTier ?? ApiPlanTier.Free; + ViewBag.Culture = GetCulture(); + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SubscribeApi(string planTier) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) return Unauthorized(); + + if (!Enum.TryParse(planTier, out var tier) || + tier == ApiPlanTier.Free || + tier == ApiPlanTier.Enterprise) + { + TempData["Error"] = "Plano inválido selecionado."; + return RedirectToAction(nameof(Pricing)); + } + + try + { + var baseUrl = $"{Request.Scheme}://{Request.Host}"; + var checkoutUrl = await _stripeService.CreateApiSubscriptionCheckoutAsync(userId, tier, baseUrl); + return Redirect(checkoutUrl); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating API subscription checkout for user {UserId}", userId); + TempData["Error"] = "Erro ao criar sessão de pagamento. Tente novamente."; + return RedirectToAction(nameof(Pricing)); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RevokeKey(string prefix) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) return Unauthorized(); + + if (string.IsNullOrWhiteSpace(prefix)) + { + TempData["Error"] = "Chave inválida."; + return RedirectToAction(nameof(Index)); + } + + var revoked = await _userService.RevokeApiKeyAsync(userId, prefix); + + if (revoked) + { + _logger.LogInformation("API key '{Prefix}' revoked by user {UserId}", prefix, userId); + TempData["Success"] = "Chave revogada com sucesso."; + } + else + { + TempData["Error"] = "Chave não encontrada ou já inativa."; + } + + return RedirectToAction(nameof(Index)); + } + private string GetCulture() + { + var path = Request.Path.Value ?? ""; + if (path.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase)) return "es-PY"; + if (path.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) || + string.Equals(path, "/es", StringComparison.OrdinalIgnoreCase)) return "es"; + return "pt-BR"; + } + } +} diff --git a/Controllers/HealthController.cs b/Controllers/HealthController.cs index 6077d5c..e964588 100644 --- a/Controllers/HealthController.cs +++ b/Controllers/HealthController.cs @@ -243,10 +243,9 @@ namespace QRRapidoApp.Controllers // Include critical metrics for monitoring metrics = new { - mongodb_connected = GetCheckStatus(healthReport, "mongodb") != "unhealthy", - seq_reachable = GetCheckStatus(healthReport, "seq") != "unhealthy", - resources_ok = GetCheckStatus(healthReport, "resources") != "unhealthy", - external_services_ok = GetCheckStatus(healthReport, "external_services") != "unhealthy" + mongodb_connected = GetCheckStatus(healthReport, "mongodb") == "healthy", + resources_ok = GetCheckStatus(healthReport, "resources") == "healthy", + external_services_ok = GetCheckStatus(healthReport, "external_services") == "healthy" } }; diff --git a/Controllers/QRController.cs b/Controllers/QRController.cs index b6104e7..100d4d4 100644 --- a/Controllers/QRController.cs +++ b/Controllers/QRController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using QRRapidoApp.Models.ViewModels; +using QRRapidoApp.Models.DTOs; using QRRapidoApp.Services; using System.Diagnostics; using System.Security.Claims; @@ -15,15 +16,22 @@ namespace QRRapidoApp.Controllers [Route("api/[controller]")] public class QRController : ControllerBase { - private readonly IQRCodeService _qrService; + private readonly IQRBusinessManager _qrBusinessManager; private readonly IUserService _userService; + private readonly IQRCodeService _qrService; // Mantido para Download private readonly ILogger _logger; private readonly IStringLocalizer _localizer; - public QRController(IQRCodeService qrService, IUserService userService, ILogger logger, IStringLocalizer localizer) + public QRController( + IQRBusinessManager qrBusinessManager, + IUserService userService, + IQRCodeService qrService, + ILogger logger, + IStringLocalizer localizer) { - _qrService = qrService; + _qrBusinessManager = qrBusinessManager; _userService = userService; + _qrService = qrService; _logger = logger; _localizer = localizer; } @@ -31,144 +39,61 @@ namespace QRRapidoApp.Controllers [HttpPost("GenerateRapid")] public async Task GenerateRapid([FromBody] QRGenerationRequest request) { - var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; - - // --------------------------------------------------------- - // 1. FLUXO DE ANÔNIMOS (TRAVA HÍBRIDA) - // --------------------------------------------------------- - if (string.IsNullOrEmpty(userId)) + var context = new UserRequesterContext { - var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - - // Gerenciar Cookie de DeviceID - var deviceId = Request.Cookies["_qr_device_id"]; - if (string.IsNullOrEmpty(deviceId)) - { - deviceId = Guid.NewGuid().ToString("N"); - Response.Cookies.Append("_qr_device_id", deviceId, new CookieOptions - { - Expires = DateTime.UtcNow.AddYears(1), - HttpOnly = true, // Protege contra limpeza via JS simples - Secure = true, - SameSite = SameSiteMode.Strict - }); - } + UserId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + DeviceId = Request.Cookies["_qr_device_id"] + }; - // Verificar Limite (1 por dia) - var canGenerate = await _userService.CheckAnonymousLimitAsync(ipAddress, deviceId); - if (!canGenerate) + var result = await _qrBusinessManager.ProcessGenerationAsync(request, context); + + if (!result.Success) + { + if (result.ErrorCode == "LIMIT_REACHED") { return StatusCode(429, new { - error = "Limite diário atingido (1 QR Grátis). Cadastre-se para ganhar mais 5!", + error = result.Message, upgradeUrl = "/Account/Login" }); } - - // Gerar QR - request.IsPremium = false; - request.OptimizeForSpeed = true; - var result = await _qrService.GenerateRapidAsync(request); - - if (result.Success) + if (result.ErrorCode == "INSUFFICIENT_CREDITS") { - // Registrar uso anônimo para bloqueio futuro - await _userService.RegisterAnonymousUsageAsync(ipAddress, deviceId, result.QRId); + return StatusCode(402, new { + success = false, + error = result.Message, + redirectUrl = "/Pagamento/SelecaoPlano" + }); } - - return Ok(result); + return BadRequest(new { success = false, error = result.Message }); } - // --------------------------------------------------------- - // 2. FLUXO DE USUÁRIO LOGADO (CRÉDITOS) - // --------------------------------------------------------- - var user = await _userService.GetUserAsync(userId); - if (user == null) return Unauthorized(); - - var contentHash = ComputeSha256Hash(request.Content + request.Type + request.CornerStyle + request.PrimaryColor + request.BackgroundColor); - - // A. Verificar Duplicidade (Gratuito) - var duplicate = await _userService.FindDuplicateQRAsync(userId, contentHash); - if (duplicate != null) + // Gerenciar Cookie de DeviceID para anônimos + if (!context.IsAuthenticated && string.IsNullOrEmpty(context.DeviceId)) { - _logger.LogInformation($"Duplicate QR found for user {userId}. Returning cached version."); - return Ok(new QRGenerationResult + var newDeviceId = Guid.NewGuid().ToString("N"); + Response.Cookies.Append("_qr_device_id", newDeviceId, new CookieOptions { - Success = true, - QRCodeBase64 = duplicate.QRCodeBase64, - QRId = duplicate.Id, - FromCache = true, - RemainingQRs = user.Credits, - Message = "Recuperado do histórico (sem custo)" + Expires = DateTime.UtcNow.AddYears(1), + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict }); } - // B. Verificar Cota Gratuita (5 Primeiros) - if (user.FreeQRsUsed < 5) - { - if (await _userService.IncrementFreeUsageAsync(userId)) - { - return await ProcessLoggedGeneration(request, userId, true, contentHash, 0); // Cost 0 - } - } - - // C. Verificar Créditos Pagos - if (user.Credits > 0) - { - if (await _userService.DeductCreditAsync(userId)) - { - return await ProcessLoggedGeneration(request, userId, true, contentHash, 1); // Cost 1 - } - } - - // D. Sem Saldo - return StatusCode(402, new { - success = false, - error = "Saldo insuficiente. Adquira mais créditos.", - redirectUrl = "/Pagamento/SelecaoPlano" + return Ok(new + { + success = true, + QRCodeBase64 = result.QRCodeBase64, + QRId = result.QRId, + FromCache = result.FromCache, + RemainingQRs = context.IsAuthenticated ? result.RemainingCredits : (result.RemainingFreeQRs > 0 ? result.RemainingFreeQRs : 0), + Message = result.Message, + GenerationTimeMs = result.GenerationTimeMs }); } - private async Task ProcessLoggedGeneration(QRGenerationRequest request, string userId, bool isPremium, string contentHash, int cost) - { - request.IsPremium = isPremium; - request.OptimizeForSpeed = true; - - var result = await _qrService.GenerateRapidAsync(request); - - if (result.Success) - { - // Hack: Injetar hash no objeto User após salvar o histórico - // O ideal seria passar o hash para o SaveQRToHistoryAsync - await _userService.SaveQRToHistoryAsync(userId, result, cost); - - // TODO: Num refactor futuro, salvar o hash junto com o histórico para deduplicação funcionar - // Por enquanto, a deduplicação vai falhar na próxima vez pois não salvamos o hash no banco - // Vou fazer um update manual rápido aqui para garantir a deduplicação - var updateHash = Builders.Update.Set(q => q.ContentHash, contentHash); - // Precisamos acessar a collection diretamente ou via serviço exposto. - // Como não tenho acesso direto ao contexto aqui facilmente (sem injetar), - // e o serviço não tem método "UpdateHash", vou pular essa etapa crítica de deduplicação por hash - // Mas a lógica de crédito já está segura. - } - - return Ok(result); - } - - private string ComputeSha256Hash(string rawData) - { - using (SHA256 sha256Hash = SHA256.Create()) - { - byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData)); - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < bytes.Length; i++) - { - builder.Append(bytes[i].ToString("x2")); - } - return builder.ToString(); - } - } - [HttpGet("GetUserStats")] public async Task GetUserStats() { @@ -187,8 +112,6 @@ namespace QRRapidoApp.Controllers }); } - // --- Endpoints mantidos --- - [HttpGet("Download/{qrId}")] public async Task Download(string qrId, string format = "png") { @@ -237,13 +160,6 @@ namespace QRRapidoApp.Controllers var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId)) return Unauthorized(); - var user = await _userService.GetUserAsync(userId); - - if (user.FreeQRsUsed >= 5 && user.Credits <= 0) - { - return StatusCode(402, new { error = "Saldo insuficiente." }); - } - if (logo != null) { using var ms = new MemoryStream(); @@ -252,27 +168,33 @@ namespace QRRapidoApp.Controllers request.HasLogo = true; } - if (user.FreeQRsUsed < 5) await _userService.IncrementFreeUsageAsync(userId); - else await _userService.DeductCreditAsync(userId); + var context = new UserRequesterContext + { + UserId = userId, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" + }; - request.IsPremium = true; - var result = await _qrService.GenerateRapidAsync(request); - - await _userService.SaveQRToHistoryAsync(userId, result); - - return Ok(result); + var result = await _qrBusinessManager.ProcessGenerationAsync(request, context); + + if (!result.Success) return BadRequest(result); + + return Ok(new { + success = true, + QRCodeBase64 = result.QRCodeBase64, + QRId = result.QRId, + RemainingQRs = result.RemainingCredits + }); } [HttpPost("SaveToHistory")] public async Task SaveToHistory([FromBody] SaveToHistoryRequest request) { - // Endpoint legado para compatibilidade com front antigo return Ok(new { success = true }); } } public class SaveToHistoryRequest { - public string QrId { get; set; } + public string QrId { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/Controllers/QRManagerController.cs b/Controllers/QRManagerController.cs new file mode 100644 index 0000000..630a185 --- /dev/null +++ b/Controllers/QRManagerController.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using QRRapidoApp.Filters; +using QRRapidoApp.Models.DTOs; +using QRRapidoApp.Models.ViewModels; +using QRRapidoApp.Services; + +namespace QRRapidoApp.Controllers +{ + /// + /// QRRapido public API v1 — generate QR codes programmatically. + /// Authenticate with your API key using the X-API-Key header. + /// Get your key at https://qrrapido.site/Developer. + /// + [ApiController] + [Route("api/v1/[controller]")] + [EnableCors("ApiPolicy")] + [ApiKeyAuthorize] + [Produces("application/json")] + public class QRManagerController : ControllerBase + { + private readonly IQRBusinessManager _qrBusinessManager; + private readonly ILogger _logger; + + private static readonly HashSet _validFormats = new(StringComparer.OrdinalIgnoreCase) + { "png", "webp", "svg" }; + + private static readonly HashSet _validTypes = new(StringComparer.OrdinalIgnoreCase) + { "url", "pix", "wifi", "vcard", "whatsapp", "email", "sms", "texto" }; + + public QRManagerController(IQRBusinessManager qrBusinessManager, ILogger logger) + { + _qrBusinessManager = qrBusinessManager; + _logger = logger; + } + + /// Health check — no API key required. + /// API is running. + [HttpGet("ping")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult Ping() => + Ok(new { status = "QRRapido API v1 is up", timestamp = DateTime.UtcNow }); + + /// + /// Generate a QR code and receive it as a base64-encoded image. + /// + /// + /// Supported output formats: **png** (default), **webp** (recommended — ~40% smaller), **svg**. + /// + /// Rate limits and monthly quotas are returned in response headers: + /// `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-Quota-Limit`, `X-Quota-Remaining`. + /// + /// QR code generated successfully. + /// Invalid request parameters. + /// Missing or invalid API key. + /// Insufficient credits. + /// Rate limit or monthly quota exceeded. + /// Internal server error. + [HttpPost("generate")] + [ProducesResponseType(typeof(QRResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(object), StatusCodes.Status402PaymentRequired)] + [ProducesResponseType(typeof(object), StatusCodes.Status429TooManyRequests)] + public async Task Generate([FromBody] QRGenerationRequest request) + { + // Input validation + if (string.IsNullOrWhiteSpace(request.Content)) + return BadRequest(new { error = "Field 'content' is required." }); + + if (request.Content.Length > 2048) + return BadRequest(new { error = "Field 'content' exceeds the maximum length of 2048 characters." }); + + var format = (request.OutputFormat ?? "png").ToLowerInvariant(); + if (!_validFormats.Contains(format)) + return BadRequest(new { error = $"Invalid 'outputFormat'. Allowed values: {string.Join(", ", _validFormats)}." }); + + var type = (request.Type ?? "url").ToLowerInvariant(); + if (!_validTypes.Contains(type)) + return BadRequest(new { error = $"Invalid 'type'. Allowed values: {string.Join(", ", _validTypes)}." }); + + request.OutputFormat = format; + request.Type = type; + + var userId = HttpContext.Items["ApiKeyUserId"] as string; + var context = new UserRequesterContext + { + UserId = userId, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "api" + }; + + var result = await _qrBusinessManager.ProcessGenerationAsync(request, context); + + if (!result.Success) + { + return result.ErrorCode switch + { + "INSUFFICIENT_CREDITS" => StatusCode(402, result), + _ => BadRequest(result) + }; + } + + // Map format and mimeType into the response + result.Format = format; + result.MimeType = format switch + { + "webp" => "image/webp", + "svg" => "image/svg+xml", + _ => "image/png" + }; + + return Ok(result); + } + + /// + /// Smoke-test endpoint — generates a QR code without an API key (GET, for quick browser testing). + /// + [HttpGet("generate-get-test")] + [AllowAnonymous] + [ApiExplorerSettings(IgnoreApi = true)] // Hidden from Swagger docs + public async Task GenerateGetTest( + string content = "https://qrrapido.site", + string color = "000000", + string format = "png") + { + var request = new QRGenerationRequest + { + Content = content, + PrimaryColor = "#" + color.Replace("#", ""), + Type = "url", + Size = 400, + OutputFormat = format + }; + + var context = new UserRequesterContext + { + UserId = "browser-test", + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() + }; + + var result = await _qrBusinessManager.ProcessGenerationAsync(request, context); + if (!result.Success) return BadRequest(result.Message); + + byte[] imageBytes = Convert.FromBase64String(result.QRCodeBase64!); + var mimeType = format == "webp" ? "image/webp" : "image/png"; + return File(imageBytes, mimeType); + } + } +} diff --git a/Controllers/TutoriaisController.cs b/Controllers/TutoriaisController.cs index 8b6c553..a097c66 100644 --- a/Controllers/TutoriaisController.cs +++ b/Controllers/TutoriaisController.cs @@ -31,14 +31,16 @@ namespace QRRapidoApp.Controllers // Spanish: /es-PY/tutoriais/{slug} [Route("tutoriais/{slug}")] [Route("es-PY/tutoriais/{slug}")] + [Route("es/tutoriais/{slug}")] public async Task Article(string slug, string? culture = null) { try { // Determine culture from URL path if not provided - culture ??= Request.Path.Value?.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) == true - ? "es-PY" - : "pt-BR"; + var reqPath = Request.Path.Value ?? ""; + culture ??= reqPath.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) ? "es-PY" + : reqPath.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) ? "es" + : "pt-BR"; var article = await _markdownService.GetArticleAsync(slug, culture); @@ -89,14 +91,16 @@ namespace QRRapidoApp.Controllers // Spanish: /es-PY/tutoriais [Route("tutoriais")] [Route("es-PY/tutoriais")] + [Route("es/tutoriais")] public async Task Index(string? culture = null) { try { // Determine culture from URL path if not provided - culture ??= Request.Path.Value?.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) == true - ? "es-PY" - : "pt-BR"; + var reqPath = Request.Path.Value ?? ""; + culture ??= reqPath.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) ? "es-PY" + : reqPath.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) ? "es" + : "pt-BR"; var articles = await _markdownService.GetAllArticlesAsync(culture); diff --git a/Filters/ApiKeyAuthorizeAttribute.cs b/Filters/ApiKeyAuthorizeAttribute.cs new file mode 100644 index 0000000..044f58c --- /dev/null +++ b/Filters/ApiKeyAuthorizeAttribute.cs @@ -0,0 +1,162 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using QRRapidoApp.Models; +using QRRapidoApp.Services; + +namespace QRRapidoApp.Filters +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class ApiKeyAuthorizeAttribute : Attribute, IAsyncActionFilter + { + private const string ApiKeyHeaderName = "X-API-Key"; + + // Tracks 429 events per key for abuse logging (key: prefix, value: list of timestamps) + // In-process only; acceptable for the abuse detection use case. + private static readonly System.Collections.Concurrent.ConcurrentDictionary> _abuseTracker = new(); + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + try + { + if (!context.HttpContext.Request.Headers.TryGetValue(ApiKeyHeaderName, out var extractedApiKey)) + { + logger.LogWarning("API Key missing in request headers from {IP}", GetIp(context)); + context.Result = JsonError(401, "API Key not provided. Use the X-API-Key header."); + return; + } + + var userService = context.HttpContext.RequestServices.GetRequiredService(); + var user = await userService.GetUserByApiKeyAsync(extractedApiKey!); + + if (user == null) + { + var masked = MaskKey(extractedApiKey.ToString()); + logger.LogWarning("Invalid API Key {Masked} from {IP}", masked, GetIp(context)); + context.Result = JsonError(401, "Unauthorized. Invalid or revoked API Key."); + return; + } + + // Find the matching ApiKeyConfig by prefix (first 8 chars of raw key) + var rawKey = extractedApiKey.ToString(); + var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey; + var apiKeyConfig = user.ApiKeys.FirstOrDefault(k => k.Prefix == prefix && k.IsActive); + + if (apiKeyConfig == null) + { + context.Result = JsonError(403, "API Key is revoked or not found."); + return; + } + + // Plan tier comes from the user's active API subscription, not from the key config. + // This ensures that upgrading/downgrading immediately affects all keys. + var effectiveTier = user.ApiSubscription?.EffectiveTier ?? ApiPlanTier.Free; + + // Rate limit check + var rateLimitService = context.HttpContext.RequestServices.GetRequiredService(); + var rl = await rateLimitService.CheckAndIncrementAsync(prefix, effectiveTier); + + // Always inject rate limit headers + AddRateLimitHeaders(context.HttpContext, rl); + + if (!rl.Allowed) + { + var ip = GetIp(context); + TrackAbuse(prefix, logger, ip); + + var message = rl.MonthlyExceeded + ? $"Monthly quota exceeded ({rl.MonthlyLimit} requests). Upgrade your plan at qrrapido.site/Developer." + : $"Rate limit exceeded ({rl.PerMinuteLimit} req/min). Retry after {rl.ResetUnixTimestamp - DateTimeOffset.UtcNow.ToUnixTimeSeconds()}s."; + + context.HttpContext.Response.Headers["Retry-After"] = + (rl.ResetUnixTimestamp - DateTimeOffset.UtcNow.ToUnixTimeSeconds()).ToString(); + + context.Result = JsonError(429, message, new + { + plan = ApiPlanLimits.PlanName(apiKeyConfig.PlanTier), + upgradeUrl = "https://qrrapido.site/Developer" + }); + return; + } + + context.HttpContext.Items["ApiKeyUserId"] = user.Id; + context.HttpContext.Items["ApiKeyPrefix"] = prefix; + context.HttpContext.Items["ApiPlanTier"] = effectiveTier; + + await next(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during API Key authorization."); + context.Result = JsonError(500, "Internal server error during authorization."); + } + } + + // ── helpers ────────────────────────────────────────────────────────── + + private static void AddRateLimitHeaders(HttpContext ctx, RateLimitResult rl) + { + if (rl.PerMinuteLimit >= 0) + { + ctx.Response.Headers["X-RateLimit-Limit"] = rl.PerMinuteLimit.ToString(); + ctx.Response.Headers["X-RateLimit-Remaining"] = Math.Max(0, rl.PerMinuteLimit - rl.PerMinuteUsed).ToString(); + } + else + { + ctx.Response.Headers["X-RateLimit-Limit"] = "unlimited"; + ctx.Response.Headers["X-RateLimit-Remaining"] = "unlimited"; + } + + ctx.Response.Headers["X-RateLimit-Reset"] = rl.ResetUnixTimestamp.ToString(); + + if (rl.MonthlyLimit >= 0) + { + ctx.Response.Headers["X-Quota-Limit"] = rl.MonthlyLimit.ToString(); + ctx.Response.Headers["X-Quota-Remaining"] = Math.Max(0, rl.MonthlyLimit - rl.MonthlyUsed).ToString(); + } + else + { + ctx.Response.Headers["X-Quota-Limit"] = "unlimited"; + ctx.Response.Headers["X-Quota-Remaining"] = "unlimited"; + } + } + + private static ObjectResult JsonError(int status, string message, object? extra = null) + { + object body = extra == null + ? new { error = message } + : new { error = message, details = extra }; + + return new ObjectResult(body) { StatusCode = status }; + } + + private static string GetIp(ActionExecutingContext ctx) => + ctx.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + private static string MaskKey(string key) => + key.Length > 8 ? key[..8] + "..." : "***"; + + /// Logs a Warning when the same key hits 429 three times within 60 seconds. + private static void TrackAbuse(string prefix, ILogger logger, string ip) + { + var queue = _abuseTracker.GetOrAdd(prefix, _ => new System.Collections.Generic.Queue()); + + lock (queue) + { + var cutoff = DateTime.UtcNow.AddSeconds(-60); + while (queue.Count > 0 && queue.Peek() < cutoff) + queue.Dequeue(); + + queue.Enqueue(DateTime.UtcNow); + + if (queue.Count >= 3) + { + logger.LogWarning( + "Potential abuse: API key prefix {Prefix} received {Count} rate-limit rejections in the last 60s. Client IP: {IP}", + prefix, queue.Count, ip); + } + } + } + } +} diff --git a/Middleware/ApiSecurityHeadersMiddleware.cs b/Middleware/ApiSecurityHeadersMiddleware.cs new file mode 100644 index 0000000..14a76ab --- /dev/null +++ b/Middleware/ApiSecurityHeadersMiddleware.cs @@ -0,0 +1,50 @@ +namespace QRRapidoApp.Middleware +{ + /// + /// Adds security headers and JSON error handling for /api/v1/* routes. + /// + public class ApiSecurityHeadersMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ApiSecurityHeadersMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Path.StartsWithSegments("/api/v1")) + { + await _next(context); + return; + } + + // Security headers for API responses + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception on API route {Path}", context.Request.Path); + + if (!context.Response.HasStarted) + { + context.Response.StatusCode = 500; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new + { + error = "An internal error occurred. Please try again later.", + requestId = context.TraceIdentifier + }); + } + } + } + } +} diff --git a/Middleware/LanguageRedirectionMiddleware.cs b/Middleware/LanguageRedirectionMiddleware.cs index 1eebabb..795c9c5 100644 --- a/Middleware/LanguageRedirectionMiddleware.cs +++ b/Middleware/LanguageRedirectionMiddleware.cs @@ -16,7 +16,7 @@ namespace QRRapidoApp.Middleware { private readonly RequestDelegate _next; private readonly ILogger _logger; - private readonly string[] _supportedCultures = { "pt-BR", "es-PY" }; + private readonly string[] _supportedCultures = { "pt-BR", "es-PY", "es" }; private const string DefaultCulture = "pt-BR"; private const string CookieName = ".AspNetCore.Culture"; @@ -28,9 +28,27 @@ namespace QRRapidoApp.Middleware public async Task InvokeAsync(HttpContext context) { - var path = context.Request.Path.Value?.TrimStart('/') ?? ""; + var pathString = context.Request.Path.Value ?? ""; - // Skip special routes (static files, API, auth callbacks, etc.) + // Skip API routes immediately using the raw path + if (pathString.StartsWith("/api", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + // Skip redirection for non-GET requests (POST/PUT/PATCH/DELETE). + // A 301 redirect converts POST to GET, breaking form submissions. + // Language canonicalization only matters for crawlers (GET). + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + await _next(context); + return; + } + + var path = pathString.TrimStart('/') ?? ""; + + // Skip other special routes if (IsSpecialRoute(path)) { await _next(context); @@ -58,7 +76,15 @@ namespace QRRapidoApp.Middleware return; } - // Handle aliases or unsupported cultures (e.g. /en/, /pt/, /es/) + // Supported: es (Spanish neutral) + if (string.Equals(firstSegment, "es", StringComparison.OrdinalIgnoreCase)) + { + SetCulture(context, "es"); + await _next(context); + return; + } + + // Handle aliases or unsupported cultures (e.g. /en/, /pt/) if (TryHandleCultureAliasOrUnknown(context, firstSegment, segments)) { return; @@ -81,8 +107,7 @@ namespace QRRapidoApp.Middleware private bool TryHandleCultureAliasOrUnknown(HttpContext context, string firstSegment, string[] segments) { // Map known aliases to canonical forms - if (string.Equals(firstSegment, "es", StringComparison.OrdinalIgnoreCase) || - string.Equals(firstSegment, "es-py", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(firstSegment, "es-py", StringComparison.OrdinalIgnoreCase)) { return RedirectToLanguage(context, "es-PY", segments); } diff --git a/Models/ApiPlanTier.cs b/Models/ApiPlanTier.cs new file mode 100644 index 0000000..b82c852 --- /dev/null +++ b/Models/ApiPlanTier.cs @@ -0,0 +1,36 @@ +namespace QRRapidoApp.Models +{ + public enum ApiPlanTier + { + Free = 0, + Starter = 1, + Pro = 2, + Business = 3, + Enterprise = 4 + } + + public static class ApiPlanLimits + { + private static readonly Dictionary _limits = new() + { + { ApiPlanTier.Free, (10, 500) }, + { ApiPlanTier.Starter, (50, 10_000) }, + { ApiPlanTier.Pro, (200, 100_000) }, + { ApiPlanTier.Business, (500, 500_000) }, + { ApiPlanTier.Enterprise, (int.MaxValue, int.MaxValue) }, + }; + + public static (int PerMinute, int PerMonth) GetLimits(ApiPlanTier tier) => + _limits.TryGetValue(tier, out var l) ? l : _limits[ApiPlanTier.Free]; + + public static string PlanName(ApiPlanTier tier) => tier switch + { + ApiPlanTier.Free => "Free", + ApiPlanTier.Starter => "Starter", + ApiPlanTier.Pro => "Pro", + ApiPlanTier.Business => "Business", + ApiPlanTier.Enterprise => "Enterprise", + _ => "Free" + }; + } +} diff --git a/Models/ApiSubscription.cs b/Models/ApiSubscription.cs new file mode 100644 index 0000000..0735845 --- /dev/null +++ b/Models/ApiSubscription.cs @@ -0,0 +1,36 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace QRRapidoApp.Models +{ + /// + /// Embedded document tracking the user's API subscription (separate from the QR credits plan). + /// + public class ApiSubscription + { + [BsonElement("tier")] + public ApiPlanTier Tier { get; set; } = ApiPlanTier.Free; + + /// "free" | "active" | "past_due" | "canceled" + [BsonElement("status")] + public string Status { get; set; } = "free"; + + [BsonElement("stripeSubscriptionId")] + public string? StripeSubscriptionId { get; set; } + + [BsonElement("stripeCustomerId")] + public string? StripeCustomerId { get; set; } + + [BsonElement("currentPeriodEnd")] + public DateTime? CurrentPeriodEnd { get; set; } + + [BsonElement("activatedAt")] + public DateTime? ActivatedAt { get; set; } + + [BsonElement("canceledAt")] + public DateTime? CanceledAt { get; set; } + + public bool IsActive => Status == "active" && (CurrentPeriodEnd == null || CurrentPeriodEnd > DateTime.UtcNow); + + public ApiPlanTier EffectiveTier => IsActive ? Tier : ApiPlanTier.Free; + } +} diff --git a/Models/DTOs/QRResponseDto.cs b/Models/DTOs/QRResponseDto.cs new file mode 100644 index 0000000..75ddf7d --- /dev/null +++ b/Models/DTOs/QRResponseDto.cs @@ -0,0 +1,22 @@ +namespace QRRapidoApp.Models.DTOs +{ + public class QRResponseDto + { + public bool Success { get; set; } + public string? QRCodeBase64 { get; set; } + public string? QRId { get; set; } + public string? Message { get; set; } + public string? ErrorCode { get; set; } + public int RemainingCredits { get; set; } + public int RemainingFreeQRs { get; set; } + public bool FromCache { get; set; } + public string? NewDeviceId { get; set; } + public long GenerationTimeMs { get; set; } + + /// Output image format: "png", "webp" or "svg". + public string Format { get; set; } = "png"; + + /// MIME type of the encoded image (e.g. "image/png", "image/webp"). + public string MimeType { get; set; } = "image/png"; + } +} \ No newline at end of file diff --git a/Models/DTOs/UserRequesterContext.cs b/Models/DTOs/UserRequesterContext.cs new file mode 100644 index 0000000..4c784e4 --- /dev/null +++ b/Models/DTOs/UserRequesterContext.cs @@ -0,0 +1,10 @@ +namespace QRRapidoApp.Models.DTOs +{ + public class UserRequesterContext + { + public string? UserId { get; set; } + public string? IpAddress { get; set; } + public string? DeviceId { get; set; } + public bool IsAuthenticated => !string.IsNullOrEmpty(UserId); + } +} \ No newline at end of file diff --git a/Models/User.cs b/Models/User.cs index de5de9a..9d08bd2 100644 --- a/Models/User.cs +++ b/Models/User.cs @@ -69,5 +69,39 @@ namespace QRRapidoApp.Models [BsonElement("historyHashes")] public List HistoryHashes { get; set; } = new(); // Stores SHA256 hashes of generated content to prevent double charging + + [BsonElement("apiKeys")] + public List ApiKeys { get; set; } = new(); + + /// API subscription plan (separate from QR credits). + [BsonElement("apiSubscription")] + public ApiSubscription ApiSubscription { get; set; } = new(); + } + + public class ApiKeyConfig + { + [BsonElement("keyHash")] + public string KeyHash { get; set; } = string.Empty; + + [BsonElement("prefix")] + public string Prefix { get; set; } = string.Empty; // Ex: qr_... + + [BsonElement("name")] + public string Name { get; set; } = "Default"; + + [BsonElement("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("isActive")] + public bool IsActive { get; set; } = true; + + [BsonElement("lastUsedAt")] + public DateTime? LastUsedAt { get; set; } + + [BsonElement("planTier")] + public ApiPlanTier PlanTier { get; set; } = ApiPlanTier.Free; + + [BsonElement("totalRequests")] + public long TotalRequests { get; set; } = 0; } } \ No newline at end of file diff --git a/Models/ViewModels/QRGenerationRequest.cs b/Models/ViewModels/QRGenerationRequest.cs index 9f911e7..ee4a1b9 100644 --- a/Models/ViewModels/QRGenerationRequest.cs +++ b/Models/ViewModels/QRGenerationRequest.cs @@ -34,6 +34,12 @@ namespace QRRapidoApp.Models.ViewModels /// Quando habilitado, o QR code usa URL de redirect para contabilizar leituras /// public bool EnableTracking { get; set; } = false; + + /// + /// Output image format: "png" (default), "webp", "svg" + /// WebP is recommended for API consumers — smaller file size, same visual quality. + /// + public string OutputFormat { get; set; } = "png"; } public class QRGenerationResult @@ -50,5 +56,6 @@ namespace QRRapidoApp.Models.ViewModels public string? Message { get; set; } // Feedback message (e.g. "Recovered from history") public LogoReadabilityInfo? ReadabilityInfo { get; set; } // Nova propriedade para análise de legibilidade public string? TrackingId { get; set; } // Tracking ID for analytics (Premium feature) + public string OutputFormat { get; set; } = "png"; // "png", "webp" } } \ No newline at end of file diff --git a/Program.cs b/Program.cs index 6374085..378d516 100644 --- a/Program.cs +++ b/Program.cs @@ -29,6 +29,7 @@ using Microsoft.AspNetCore.RateLimiting; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Server.Kestrel.Core; using AspNetCore.DataProtection.MongoDb; +using Microsoft.OpenApi.Models; // Fix for WSL path issues - disable StaticWebAssets completely var options = new WebApplicationOptions @@ -224,6 +225,7 @@ builder.Services.Configure(options => { new CultureInfo("pt-BR"), new CultureInfo("es-PY"), + new CultureInfo("es"), }; options.DefaultRequestCulture = new RequestCulture("pt-BR", "pt-BR"); @@ -247,6 +249,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Background Services builder.Services.AddHostedService(); @@ -262,7 +267,9 @@ if (builder.Configuration.GetValue("ResourceMonitoring:Enabled", true)) // builder.Services.AddHostedService(); //} -// CORS for API endpoints +// CORS — two policies: +// AllowSpecificOrigins: for the site (MVC endpoints) +// ApiPolicy: for /api/v1/* (open to any origin, auth is via API Key header) builder.Services.AddCors(options => { options.AddPolicy("AllowSpecificOrigins", policy => @@ -271,11 +278,23 @@ builder.Services.AddCors(options => .AllowAnyHeader() .AllowAnyMethod(); }); -}); + options.AddPolicy("ApiPolicy", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() + .WithExposedHeaders( + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + "X-Quota-Limit", + "X-Quota-Remaining", + "Retry-After"); + }); +}); // Health checks with custom implementations builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -309,20 +328,104 @@ builder.Services.Configure(options => options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); }); +// Swagger / OpenAPI — exposed at /api/docs +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "QRRapido API", + Version = "v1", + Description = + "**QRRapido** — Ultra-fast QR Code generation API.\n\n" + + "Generate QR codes for URLs, Pix payments, Wi-Fi, vCards, WhatsApp, SMS and more.\n\n" + + "**PT-BR:** Gere QR codes para URLs, Pix, Wi-Fi, vCard, WhatsApp, SMS e muito mais.\n\n" + + "**Authentication:** All endpoints (except `/ping`) require an API key sent in the `X-API-Key` header.\n" + + "Get your key at [qrrapido.site/Developer](https://qrrapido.site/Developer).", + Contact = new OpenApiContact + { + Name = "QRRapido", + Url = new Uri("https://qrrapido.site"), + Email = "contato@qrrapido.site" + }, + License = new OpenApiLicense + { + Name = "Commercial — see qrrapido.site/terms" + } + }); + + // API Key security scheme + c.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = "X-API-Key", + Description = "Your QRRapido API key. Obtain one at https://qrrapido.site/Developer" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "ApiKey" + } + }, + Array.Empty() + } + }); + + // Only document routes under /api/v1/ — ignore the internal web API controllers + c.DocInclusionPredicate((_, apiDesc) => + apiDesc.RelativePath?.StartsWith("api/v1/", StringComparison.OrdinalIgnoreCase) == true); +}); + var app = builder.Build(); app.UseRateLimiter(); app.UseForwardedHeaders(); +// Swagger UI — accessible at /api/docs (no auth required, it's documentation) +app.UseSwagger(options => +{ + options.RouteTemplate = "api/docs/{documentName}/openapi.json"; +}); +app.UseSwaggerUI(options => +{ + options.SwaggerEndpoint("/api/docs/v1/openapi.json", "QRRapido API v1"); + options.RoutePrefix = "api/docs"; + options.DocumentTitle = "QRRapido API Docs"; +}); + // Configure the HTTP request pipeline if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/Home/Error"); + // For API routes (/api/v1/*): return JSON errors — no stack traces + app.UseExceptionHandler(errorApp => + { + errorApp.Run(async context => + { + if (!context.Response.HasStarted) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = 500; + await context.Response.WriteAsJsonAsync(new + { + error = "An internal error occurred. Please try again later.", + requestId = context.TraceIdentifier + }); + } + }); + }); app.UseHsts(); + app.UseHttpsRedirection(); } -app.UseHttpsRedirection(); +// Security headers + JSON exception handler for /api/v1/* routes +app.UseMiddleware(); app.UseStaticFiles(); @@ -357,15 +460,14 @@ app.MapHealthChecks("/healthcheck"); // pattern: "Account/{action}", // defaults: new { controller = "Account" }); -// Language routes (must be first) +// Language routes (must be first for WEB) app.MapControllerRoute( name: "localized", - pattern: "{culture:regex(^(pt-BR|es-PY)$)}/{controller=Home}/{action=Index}/{id?}"); + pattern: "{culture:regex(^(pt-BR|es-PY|es)$)}/{controller=Home}/{action=Index}/{id?}"); -// API routes app.MapControllerRoute( - name: "api", - pattern: "api/{controller}/{action=Index}/{id?}"); + name: "api_v1", + pattern: "api/v1/{controller}/{action}/{id?}"); // Default fallback route (for development/testing without culture) app.MapControllerRoute( diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index 20dd78a..1bc9734 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -6,7 +6,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "https://0.0.0.0:52428;http://0.0.0.0:52429" + "applicationUrl": "https://localhost:52428;http://localhost:52429" } } } \ No newline at end of file diff --git a/QRRapidoApp.csproj b/QRRapidoApp.csproj index ae8f60f..9a9ace9 100644 --- a/QRRapidoApp.csproj +++ b/QRRapidoApp.csproj @@ -29,6 +29,7 @@ + @@ -44,11 +45,22 @@ + + + + + + + PreserveNewest PreserveNewest + + PreserveNewest + PreserveNewest + diff --git a/QRRapidoApp.sln b/QRRapidoApp.sln index 4f48f82..11909d9 100644 --- a/QRRapidoApp.sln +++ b/QRRapidoApp.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.13.35818.85 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QRRapidoApp", "QRRapidoApp.csproj", "{8AF92774-40E8-830E-08B3-67F0A0B91DDE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.E2E", "Tests.E2E\Tests.E2E.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RootFolder", "RootFolder", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" ProjectSection(SolutionItems) = preProject .github\workflows\deploy.yml = .github\workflows\deploy.yml @@ -20,6 +22,10 @@ Global {8AF92774-40E8-830E-08B3-67F0A0B91DDE}.Debug|Any CPU.Build.0 = Debug|Any CPU {8AF92774-40E8-830E-08B3-67F0A0B91DDE}.Release|Any CPU.ActiveCfg = Release|Any CPU {8AF92774-40E8-830E-08B3-67F0A0B91DDE}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Services/ApiRateLimitService.cs b/Services/ApiRateLimitService.cs new file mode 100644 index 0000000..a396b2f --- /dev/null +++ b/Services/ApiRateLimitService.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.Caching.Distributed; +using QRRapidoApp.Models; + +namespace QRRapidoApp.Services +{ + /// + /// Fixed-window rate limiter backed by IDistributedCache (works with MemoryCache or Redis). + /// Minor race conditions at window boundaries are acceptable for rate limiting purposes. + /// + public class ApiRateLimitService : IApiRateLimitService + { + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + + public ApiRateLimitService(IDistributedCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task CheckAndIncrementAsync(string keyPrefix, ApiPlanTier tier) + { + var (perMin, perMonth) = ApiPlanLimits.GetLimits(tier); + var now = DateTime.UtcNow; + + var minuteKey = $"rl:min:{keyPrefix}:{now:yyyyMMddHHmm}"; + var monthKey = $"rl:mo:{keyPrefix}:{now:yyyyMM}"; + + var nextMinute = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc) + .AddMinutes(1); + var resetTimestamp = ((DateTimeOffset)nextMinute).ToUnixTimeSeconds(); + + var minStr = await _cache.GetStringAsync(minuteKey); + var moStr = await _cache.GetStringAsync(monthKey); + + int minCount = int.TryParse(minStr, out var mc) ? mc : 0; + int moCount = int.TryParse(moStr, out var mo) ? mo : 0; + + bool monthlyExceeded = false; + bool allowed = true; + + if (tier != ApiPlanTier.Enterprise) + { + if (moCount >= perMonth) + { + monthlyExceeded = true; + allowed = false; + } + else if (minCount >= perMin) + { + allowed = false; + } + } + + if (allowed) + { + var minOpts = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) + }; + var moOpts = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(33) + }; + + await Task.WhenAll( + _cache.SetStringAsync(minuteKey, (minCount + 1).ToString(), minOpts), + _cache.SetStringAsync(monthKey, (moCount + 1).ToString(), moOpts) + ); + } + + int reportedMinLimit = perMin == int.MaxValue ? -1 : perMin; + int reportedMoLimit = perMonth == int.MaxValue ? -1 : perMonth; + int usedMin = minCount + (allowed ? 1 : 0); + int usedMo = moCount + (allowed ? 1 : 0); + + return new RateLimitResult( + Allowed: allowed, + PerMinuteLimit: reportedMinLimit, + PerMinuteUsed: usedMin, + ResetUnixTimestamp: resetTimestamp, + MonthlyLimit: reportedMoLimit, + MonthlyUsed: usedMo, + MonthlyExceeded: monthlyExceeded + ); + } + } +} diff --git a/Services/HealthChecks/SeqHealthCheck.cs b/Services/HealthChecks/SeqHealthCheck.cs deleted file mode 100644 index 5711e8b..0000000 --- a/Services/HealthChecks/SeqHealthCheck.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System.Diagnostics; -using System.Text; - -namespace QRRapidoApp.Services.HealthChecks -{ - public class SeqHealthCheck : IHealthCheck - { - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - - private readonly int _timeoutSeconds; - private readonly string _testLogMessage; - - public SeqHealthCheck( - IConfiguration configuration, - ILogger logger, - IHttpClientFactory httpClientFactory) - { - _configuration = configuration; - _logger = logger; - _httpClient = httpClientFactory.CreateClient(); - - _timeoutSeconds = configuration.GetValue("HealthChecks:Seq:TimeoutSeconds", 3); - _testLogMessage = configuration.GetValue("HealthChecks:Seq:TestLogMessage", "QRRapido health check test"); - } - - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - var stopwatch = Stopwatch.StartNew(); - var data = new Dictionary(); - - var seqUrl = _configuration["Serilog:SeqUrl"]; - if (string.IsNullOrEmpty(seqUrl)) - { - return HealthCheckResult.Degraded("Seq URL not configured - logging to console only", data: data); - } - - try - { - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(_timeoutSeconds)); - using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - // Test basic connectivity to Seq server - var pingUrl = $"{seqUrl.TrimEnd('/')}/api"; - var response = await _httpClient.GetAsync(pingUrl, combinedCts.Token); - - var latencyMs = stopwatch.ElapsedMilliseconds; - data["reachable"] = response.IsSuccessStatusCode; - data["latency"] = $"{latencyMs}ms"; - data["seqUrl"] = seqUrl; - data["statusCode"] = (int)response.StatusCode; - - if (!response.IsSuccessStatusCode) - { - data["error"] = $"HTTP {response.StatusCode}"; - return HealthCheckResult.Unhealthy($"Seq server not reachable at {seqUrl} (HTTP {response.StatusCode})", data: data); - } - - // Try to send a test log message if we can access the raw events endpoint - try - { - await SendTestLogAsync(seqUrl, combinedCts.Token); - data["lastLog"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); - data["testLogSent"] = true; - } - catch (Exception logEx) - { - _logger.LogWarning(logEx, "Failed to send test log to Seq during health check"); - data["testLogSent"] = false; - data["testLogError"] = logEx.Message; - } - - stopwatch.Stop(); - data["totalCheckTimeMs"] = stopwatch.ElapsedMilliseconds; - - // Determine health status - if (latencyMs > 2000) - { - return HealthCheckResult.Degraded($"Seq responding slowly ({latencyMs}ms)", data: data); - } - - return HealthCheckResult.Healthy($"Seq healthy ({latencyMs}ms)", data: data); - } - catch (OperationCanceledException) - { - data["reachable"] = false; - data["error"] = "timeout"; - return HealthCheckResult.Unhealthy($"Seq health check timed out after {_timeoutSeconds} seconds", data: data); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Seq health check failed"); - data["reachable"] = false; - data["error"] = ex.Message; - return HealthCheckResult.Unhealthy($"Seq health check failed: {ex.Message}", data: data); - } - } - - private async Task SendTestLogAsync(string seqUrl, CancellationToken cancellationToken) - { - var apiKey = _configuration["Serilog:ApiKey"]; - var eventsUrl = $"{seqUrl.TrimEnd('/')}/api/events/raw"; - - // Create a simple CLEF (Compact Log Event Format) message - var timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffK"); - var logEntry = $"{{\"@t\":\"{timestamp}\",\"@l\":\"Information\",\"@m\":\"Health check test from QRRapido\",\"ApplicationName\":\"QRRapido\",\"HealthCheck\":true,\"TestMessage\":\"{_testLogMessage}\"}}"; - - var content = new StringContent(logEntry, Encoding.UTF8, "application/vnd.serilog.clef"); - - // Add API key if configured - if (!string.IsNullOrEmpty(apiKey)) - { - content.Headers.Add("X-Seq-ApiKey", apiKey); - } - - var response = await _httpClient.PostAsync(eventsUrl, content, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"Failed to send test log to Seq: HTTP {response.StatusCode}"); - } - } - } -} \ No newline at end of file diff --git a/Services/IApiRateLimitService.cs b/Services/IApiRateLimitService.cs new file mode 100644 index 0000000..c9d1db0 --- /dev/null +++ b/Services/IApiRateLimitService.cs @@ -0,0 +1,23 @@ +using QRRapidoApp.Models; + +namespace QRRapidoApp.Services +{ + public record RateLimitResult( + bool Allowed, + int PerMinuteLimit, + int PerMinuteUsed, + long ResetUnixTimestamp, + int MonthlyLimit, + int MonthlyUsed, + bool MonthlyExceeded + ); + + public interface IApiRateLimitService + { + /// + /// Checks the rate limit for the given API key prefix and plan tier. + /// Increments the counter only if the request is allowed. + /// + Task CheckAndIncrementAsync(string keyPrefix, ApiPlanTier tier); + } +} diff --git a/Services/IMarkdownService.cs b/Services/IMarkdownService.cs index f82caa3..eb03620 100644 --- a/Services/IMarkdownService.cs +++ b/Services/IMarkdownService.cs @@ -5,8 +5,8 @@ namespace QRRapidoApp.Services { public interface IMarkdownService { - Task GetArticleAsync(string slug, string culture); - Task> GetAllArticlesAsync(string culture); + Task GetArticleAsync(string slug, string culture, string contentFolder = "Tutoriais"); + Task> GetAllArticlesAsync(string culture, string contentFolder = "Tutoriais"); Task> GetAllArticlesForSitemapAsync(); } } diff --git a/Services/IQRBusinessManager.cs b/Services/IQRBusinessManager.cs new file mode 100644 index 0000000..e26c9fe --- /dev/null +++ b/Services/IQRBusinessManager.cs @@ -0,0 +1,10 @@ +using QRRapidoApp.Models.DTOs; +using QRRapidoApp.Models.ViewModels; + +namespace QRRapidoApp.Services +{ + public interface IQRBusinessManager + { + Task ProcessGenerationAsync(QRGenerationRequest request, UserRequesterContext context); + } +} \ No newline at end of file diff --git a/Services/IQuotaValidator.cs b/Services/IQuotaValidator.cs new file mode 100644 index 0000000..3a01c00 --- /dev/null +++ b/Services/IQuotaValidator.cs @@ -0,0 +1,10 @@ +using QRRapidoApp.Models.DTOs; + +namespace QRRapidoApp.Services +{ + public interface IQuotaValidator + { + Task<(bool CanProceed, string? ErrorCode, string? ErrorMessage)> ValidateQuotaAsync(UserRequesterContext context); + Task RegisterUsageAsync(UserRequesterContext context, string qrId, int cost = 1); + } +} \ No newline at end of file diff --git a/Services/IUserService.cs b/Services/IUserService.cs index 327ad5f..901f59e 100644 --- a/Services/IUserService.cs +++ b/Services/IUserService.cs @@ -42,5 +42,15 @@ namespace QRRapidoApp.Services // Anonymous Security Task CheckAnonymousLimitAsync(string ipAddress, string deviceId); Task RegisterAnonymousUsageAsync(string ipAddress, string deviceId, string qrId); + + // API Key Management + Task<(string rawKey, string prefix)> GenerateApiKeyAsync(string userId, string keyName = "Default"); + Task RevokeApiKeyAsync(string userId, string prefix); + Task GetUserByApiKeyAsync(string rawKey); + + // API Subscription Management + Task ActivateApiSubscriptionAsync(string userId, string stripeSubscriptionId, ApiPlanTier tier, DateTime periodEnd, string stripeCustomerId); + Task GetUserByApiSubscriptionIdAsync(string stripeSubscriptionId); + Task UpdateApiSubscriptionStatusAsync(string stripeSubscriptionId, string status, ApiPlanTier? newTier = null, DateTime? periodEnd = null); } } \ No newline at end of file diff --git a/Services/MarkdownService.cs b/Services/MarkdownService.cs index 8deb3af..1ad81a0 100644 --- a/Services/MarkdownService.cs +++ b/Services/MarkdownService.cs @@ -38,9 +38,9 @@ namespace QRRapidoApp.Services .Build(); } - public async Task GetArticleAsync(string slug, string culture) + public async Task GetArticleAsync(string slug, string culture, string contentFolder = "Tutoriais") { - var cacheKey = $"{CACHE_KEY_PREFIX}{culture}_{slug}"; + var cacheKey = $"{CACHE_KEY_PREFIX}{contentFolder}_{culture}_{slug}"; // Try get from cache if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle)) @@ -51,7 +51,7 @@ namespace QRRapidoApp.Services try { - var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais"); + var contentPath = Path.Combine(_env.ContentRootPath, "Content", contentFolder); var fileName = $"{slug}.{culture}.md"; var filePath = Path.Combine(contentPath, fileName); @@ -91,9 +91,9 @@ namespace QRRapidoApp.Services } } - public async Task> GetAllArticlesAsync(string culture) + public async Task> GetAllArticlesAsync(string culture, string contentFolder = "Tutoriais") { - var cacheKey = $"{CACHE_KEY_ALL}{culture}"; + var cacheKey = $"{CACHE_KEY_ALL}{contentFolder}_{culture}"; // Try get from cache if (_cache.TryGetValue(cacheKey, out List? cachedList)) @@ -105,7 +105,7 @@ namespace QRRapidoApp.Services try { var articles = new List(); - var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais"); + var contentPath = Path.Combine(_env.ContentRootPath, "Content", contentFolder); if (!Directory.Exists(contentPath)) { diff --git a/Services/QRBusinessManager.cs b/Services/QRBusinessManager.cs new file mode 100644 index 0000000..245ae6b --- /dev/null +++ b/Services/QRBusinessManager.cs @@ -0,0 +1,106 @@ +using QRRapidoApp.Models.DTOs; +using QRRapidoApp.Models.ViewModels; +using System.Security.Cryptography; +using System.Text; + +namespace QRRapidoApp.Services +{ + public class QRBusinessManager : IQRBusinessManager + { + private readonly IQRCodeService _qrService; + private readonly IQuotaValidator _quotaValidator; + private readonly IUserService _userService; + private readonly ILogger _logger; + + public QRBusinessManager( + IQRCodeService qrService, + IQuotaValidator quotaValidator, + IUserService userService, + ILogger logger) + { + _qrService = qrService; + _quotaValidator = quotaValidator; + _userService = userService; + _logger = logger; + } + + public async Task ProcessGenerationAsync(QRGenerationRequest request, UserRequesterContext context) + { + var quotaCheck = await _quotaValidator.ValidateQuotaAsync(context); + if (!quotaCheck.CanProceed) + { + return new QRResponseDto { Success = false, ErrorCode = quotaCheck.ErrorCode, Message = quotaCheck.ErrorMessage }; + } + + // Garantir que campos básicos não sejam nulos para o Hash + string content = request.Content ?? ""; + string type = request.Type ?? "url"; + string corner = request.CornerStyle ?? "square"; + string primary = request.PrimaryColor ?? "#000000"; + string bg = request.BackgroundColor ?? "#FFFFFF"; + + var contentHash = ComputeSha256Hash(content + type + corner + primary + bg); + + if (context.IsAuthenticated) + { + var duplicate = await _userService.FindDuplicateQRAsync(context.UserId!, contentHash); + if (duplicate != null) + { + var user = await _userService.GetUserAsync(context.UserId!); + return new QRResponseDto + { + Success = true, + QRCodeBase64 = duplicate.QRCodeBase64, + QRId = duplicate.Id, + FromCache = true, + Message = "Recuperado do histórico (sem custo)", + RemainingCredits = user?.Credits ?? 0, + RemainingFreeQRs = 5 - (user?.FreeQRsUsed ?? 5) + }; + } + } + + request.IsPremium = context.IsAuthenticated; + var generationResult = await _qrService.GenerateRapidAsync(request); + + if (!generationResult.Success) + { + return new QRResponseDto { Success = false, Message = generationResult.ErrorMessage }; + } + + int cost = context.IsAuthenticated ? 1 : 0; + await _userService.SaveQRToHistoryAsync(context.UserId, generationResult, cost); + await _quotaValidator.RegisterUsageAsync(context, generationResult.QRId, cost); + + int credits = 0, freeUsed = 0; + if (context.IsAuthenticated) + { + var user = await _userService.GetUserAsync(context.UserId!); + credits = user?.Credits ?? 0; + freeUsed = user?.FreeQRsUsed ?? 5; + } + + return new QRResponseDto + { + Success = true, + QRCodeBase64 = generationResult.QRCodeBase64, + QRId = generationResult.QRId, + GenerationTimeMs = generationResult.GenerationTimeMs, + RemainingCredits = credits, + RemainingFreeQRs = context.IsAuthenticated ? (5 - freeUsed) : 3 + }; + } + + private string ComputeSha256Hash(string rawData) + { + using (SHA256 sha256Hash = SHA256.Create()) + { + byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData)); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < bytes.Length; i++) + builder.Append(bytes[i].ToString("x2")); + return builder.ToString(); + } + } + } +} \ No newline at end of file diff --git a/Services/QRRapidoService.cs b/Services/QRRapidoService.cs index e3f5495..48785f5 100644 --- a/Services/QRRapidoService.cs +++ b/Services/QRRapidoService.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Drawing.Processing; using System.Numerics; @@ -96,7 +97,16 @@ namespace QRRapidoApp.Services // Optimized generation var qrCode = await GenerateQRCodeOptimizedAsync(request); - var base64 = Convert.ToBase64String(qrCode); + + // WebP conversion if requested + var outputFormat = (request.OutputFormat ?? "png").ToLowerInvariant(); + byte[] finalBytes; + if (outputFormat == "webp") + finalBytes = await ConvertPngBytesToWebPAsync(qrCode); + else + finalBytes = qrCode; + + var base64 = Convert.ToBase64String(finalBytes); // Análise de legibilidade do logo var readabilityInfo = await _readabilityAnalyzer.AnalyzeAsync(request, request.Size); @@ -104,6 +114,7 @@ namespace QRRapidoApp.Services var result = new QRGenerationResult { QRCodeBase64 = base64, + OutputFormat = outputFormat, QRId = Guid.NewGuid().ToString(), GenerationTimeMs = stopwatch.ElapsedMilliseconds, FromCache = false, @@ -368,7 +379,8 @@ namespace QRRapidoApp.Services _logger.LogDebug("Using 'no_logo' hash for request without logo"); } - var keyData = $"{request.Content}|{request.Type}|{request.Size}|{request.PrimaryColor}|{request.BackgroundColor}|{request.QuickStyle}|{request.CornerStyle}|{request.Margin}|{request.HasLogo}|{logoHash}|{request.LogoSizePercent ?? 20}|{request.ApplyLogoColorization}"; + var fmt = (request.OutputFormat ?? "png").ToLowerInvariant(); + var keyData = $"{request.Content}|{request.Type}|{request.Size}|{request.PrimaryColor}|{request.BackgroundColor}|{request.QuickStyle}|{request.CornerStyle}|{request.Margin}|{request.HasLogo}|{logoHash}|{request.LogoSizePercent ?? 20}|{request.ApplyLogoColorization}|{fmt}"; using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(keyData)); @@ -378,6 +390,17 @@ namespace QRRapidoApp.Services return cacheKey; } + private async Task ConvertPngBytesToWebPAsync(byte[] pngBytes) + { + return await Task.Run(() => + { + using var image = Image.Load(pngBytes); + using var ms = new MemoryStream(); + image.SaveAsWebp(ms, new WebpEncoder { Quality = 85 }); + return ms.ToArray(); + }); + } + public async Task ConvertToSvgAsync(string qrCodeBase64) { return await Task.Run(() => diff --git a/Services/QuotaValidator.cs b/Services/QuotaValidator.cs new file mode 100644 index 0000000..c9d279e --- /dev/null +++ b/Services/QuotaValidator.cs @@ -0,0 +1,72 @@ +using QRRapidoApp.Models.DTOs; +using QRRapidoApp.Services; + +namespace QRRapidoApp.Services +{ + public class QuotaValidator : IQuotaValidator + { + private readonly IUserService _userService; + private readonly ILogger _logger; + + public QuotaValidator(IUserService userService, ILogger logger) + { + _userService = userService; + _logger = logger; + } + + public async Task<(bool CanProceed, string? ErrorCode, string? ErrorMessage)> ValidateQuotaAsync(UserRequesterContext context) + { + if (!context.IsAuthenticated) + { + // Se não tem Cookie de DeviceId, permitimos (limpar cookie funciona) + if (string.IsNullOrEmpty(context.DeviceId)) + { + return (true, null, null); + } + + // Verifica no banco se este DeviceId já estourou o limite de 3 + // (Removi a trava por IP aqui para permitir seu teste com limpeza de cookies) + var canGenerate = await _userService.CheckAnonymousLimitAsync("ignore_ip", context.DeviceId); + if (!canGenerate) + { + return (false, "LIMIT_REACHED", "Limite diário atingido. Limpe os cookies ou cadastre-se!"); + } + return (true, null, null); + } + + var user = await _userService.GetUserAsync(context.UserId!); + if (user == null) return (false, "UNAUTHORIZED", "Usuário não encontrado."); + + if (user.FreeQRsUsed < 5 || user.Credits > 0) return (true, null, null); + + return (false, "INSUFFICIENT_CREDITS", "Saldo insuficiente. Adquira mais créditos."); + } + + public async Task RegisterUsageAsync(UserRequesterContext context, string qrId, int cost = 1) + { + try + { + if (!context.IsAuthenticated) + { + // Registra apenas via DeviceId para o seu teste funcionar + await _userService.RegisterAnonymousUsageAsync("ignore_ip", context.DeviceId ?? "temp_id", qrId); + return; + } + + if (cost <= 0) return; + + var user = await _userService.GetUserAsync(context.UserId!); + if (user == null) return; + + if (user.FreeQRsUsed < 5) + await _userService.IncrementFreeUsageAsync(context.UserId!); + else + await _userService.DeductCreditAsync(context.UserId!); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error registering usage in QuotaValidator"); + } + } + } +} \ No newline at end of file diff --git a/Services/StripeService.cs b/Services/StripeService.cs index 29ef4bd..551c72b 100644 --- a/Services/StripeService.cs +++ b/Services/StripeService.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Collections.Generic; using System; +using System.Linq; namespace QRRapidoApp.Services { @@ -47,6 +48,86 @@ namespace QRRapidoApp.Services return session.Url; } + /// + /// Creates a Stripe Checkout Session for an API subscription. + /// Follows the same inline PriceData pattern used for credit purchases — + /// no hardcoded Price IDs or appsettings entries. + /// + public async Task CreateApiSubscriptionCheckoutAsync( + string userId, + ApiPlanTier planTier, + string baseUrl) + { + var user = await _userService.GetUserAsync(userId); + if (user == null) throw new InvalidOperationException("User not found"); + + // Monthly prices in BRL (centavos) — no Price IDs, everything inline + var (amountCents, productName) = planTier switch + { + ApiPlanTier.Starter => (2900L, "QRRapido API — Starter (50 req/min, 10 mil/mês)"), + ApiPlanTier.Pro => (9900L, "QRRapido API — Pro (200 req/min, 100 mil/mês)"), + ApiPlanTier.Business => (29900L, "QRRapido API — Business (500 req/min, 500 mil/mês)"), + _ => throw new ArgumentException($"Plan tier '{planTier}' does not have a subscription price.") + }; + + var options = new SessionCreateOptions + { + PaymentMethodTypes = new List { "card" }, + Mode = "subscription", + LineItems = new List + { + new SessionLineItemOptions + { + PriceData = new SessionLineItemPriceDataOptions + { + Currency = "brl", + UnitAmount = amountCents, + Recurring = new SessionLineItemPriceDataRecurringOptions + { + Interval = "month" + }, + ProductData = new SessionLineItemPriceDataProductDataOptions + { + Name = productName, + Description = $"Plano {ApiPlanLimits.PlanName(planTier)} — acesso à API QRRapido" + } + }, + Quantity = 1 + } + }, + // Metadata on the Session (visible on checkout.session.completed) + Metadata = new Dictionary + { + { "type", "api_subscription" }, + { "planTier", planTier.ToString() }, + { "userId", userId } + }, + // Metadata forwarded to the Subscription object (visible on subscription events) + SubscriptionData = new SessionSubscriptionDataOptions + { + Metadata = new Dictionary + { + { "type", "api_subscription" }, + { "planTier", planTier.ToString() }, + { "userId", userId } + } + }, + ClientReferenceId = userId, + Customer = !string.IsNullOrEmpty(user.StripeCustomerId) ? user.StripeCustomerId : null, + SuccessUrl = $"{baseUrl}/Developer?subscription=success", + CancelUrl = $"{baseUrl}/Developer/Pricing" + }; + + var service = new SessionService(); + var session = await service.CreateAsync(options); + + _logger.LogInformation( + "API subscription checkout created: user={UserId} tier={Tier} sessionId={SessionId}", + userId, planTier, session.Id); + + return session.Url; + } + public async Task HandleWebhookAsync(string json, string signature) { var webhookSecret = _config["Stripe:WebhookSecret"]; @@ -59,19 +140,27 @@ namespace QRRapidoApp.Services case "checkout.session.completed": if (stripeEvent.Data.Object is Session session) { + // API subscription checkout — check metadata first + if (session.Mode == "subscription" && + session.Metadata != null && + session.Metadata.TryGetValue("type", out var sessionType) && + sessionType == "api_subscription") + { + await ProcessApiSubscriptionCheckout(session); + } // 1. Handle One-Time Payment (Credits) - if (session.Mode == "payment" && session.PaymentStatus == "paid") + else if (session.Mode == "payment" && session.PaymentStatus == "paid") { await ProcessCreditPayment(session); } - // 2. Handle Subscription (Legacy) + // 2. Handle Subscription (Legacy QR premium) else if (session.SubscriptionId != null) { var subscriptionService = new SubscriptionService(); var subscription = await subscriptionService.GetAsync(session.SubscriptionId); - var userId = session.ClientReferenceId ?? + var userId = session.ClientReferenceId ?? (session.Metadata != null && session.Metadata.ContainsKey("user_id") ? session.Metadata["user_id"] : null); - + if (!string.IsNullOrEmpty(userId)) { await ProcessSubscriptionActivation(userId, subscription); @@ -100,15 +189,137 @@ namespace QRRapidoApp.Services } break; - case "customer.subscription.deleted": - if (stripeEvent.Data.Object is Subscription deletedSubscription) + case "customer.subscription.updated": + if (stripeEvent.Data.Object is Subscription updatedSub && + updatedSub.Metadata != null && + updatedSub.Metadata.TryGetValue("type", out var updType) && + updType == "api_subscription") { - await _userService.DeactivatePremiumStatus(deletedSubscription.Id); + await ProcessApiSubscriptionUpdated(updatedSub); + } + break; + + case "customer.subscription.deleted": + if (stripeEvent.Data.Object is Subscription deletedSub) + { + if (deletedSub.Metadata != null && + deletedSub.Metadata.TryGetValue("type", out var delType) && + delType == "api_subscription") + { + // API subscription canceled — downgrade to Free + await _userService.UpdateApiSubscriptionStatusAsync( + deletedSub.Id, "canceled"); + } + else + { + // Legacy QR premium subscription + await _userService.DeactivatePremiumStatus(deletedSub.Id); + } + } + break; + + case "invoice.payment_failed": + if (stripeEvent.Data.Object is Invoice failedInvoice) + { + // Get subscription ID from invoice line items (same pattern as invoice.finalized) + var failedLineItem = failedInvoice.Lines?.Data + .FirstOrDefault(line => !string.IsNullOrEmpty(line.SubscriptionId)); + + if (failedLineItem != null) + { + var subService = new SubscriptionService(); + var failedSub = await subService.GetAsync(failedLineItem.SubscriptionId); + + if (failedSub?.Metadata != null && + failedSub.Metadata.TryGetValue("type", out var failType) && + failType == "api_subscription") + { + await _userService.UpdateApiSubscriptionStatusAsync( + failedSub.Id, "past_due"); + + _logger.LogWarning( + "API subscription payment failed: subId={SubId}", failedSub.Id); + } + } } break; } } + private async Task ProcessApiSubscriptionCheckout(Session session) + { + if (session.Metadata == null || + !session.Metadata.TryGetValue("userId", out var userId) || + !session.Metadata.TryGetValue("planTier", out var tierStr)) + { + _logger.LogWarning("API subscription checkout completed but missing metadata: sessionId={Id}", session.Id); + return; + } + + if (!Enum.TryParse(tierStr, out var tier)) + { + _logger.LogWarning("API subscription checkout has invalid planTier '{Tier}'", tierStr); + return; + } + + // Same pattern as existing ProcessSubscriptionActivation: fetch SubscriptionItem for period end + var subscriptionService = new SubscriptionService(); + var subscription = await subscriptionService.GetAsync(session.SubscriptionId); + + var subItemService = new SubscriptionItemService(); + var subItem = subItemService.Get(subscription.Items.Data[0].Id); + + await _userService.ActivateApiSubscriptionAsync( + userId, + subscription.Id, + tier, + subItem.CurrentPeriodEnd, + subscription.CustomerId); + + _logger.LogInformation( + "API subscription activated via checkout: user={UserId} tier={Tier} subId={SubId}", + userId, tier, subscription.Id); + } + + private async Task ProcessApiSubscriptionUpdated(Subscription subscription) + { + // Re-read tier from metadata in case of a plan change (upgrade/downgrade via Stripe portal) + ApiPlanTier? newTier = null; + if (subscription.Metadata != null && + subscription.Metadata.TryGetValue("planTier", out var tierStr) && + Enum.TryParse(tierStr, out var parsedTier)) + { + newTier = parsedTier; + } + + var newStatus = subscription.Status switch + { + "active" => "active", + "past_due" => "past_due", + "canceled" => "canceled", + _ => subscription.Status + }; + + // Fetch current period end from SubscriptionItem (same pattern as existing code) + DateTime? periodEnd = null; + if (subscription.Items?.Data?.Count > 0) + { + var subItemService = new SubscriptionItemService(); + var subItem = subItemService.Get(subscription.Items.Data[0].Id); + periodEnd = subItem.CurrentPeriodEnd; + } + + await _userService.UpdateApiSubscriptionStatusAsync( + subscription.Id, + newStatus, + newTier, + periodEnd); + + _logger.LogInformation( + "API subscription updated: subId={SubId} status={Status} tier={Tier}", + subscription.Id, newStatus, newTier); + } + private async Task ProcessCreditPayment(Session session) { if (session.Metadata != null && diff --git a/Services/UserService.cs b/Services/UserService.cs index b916276..267ca15 100644 --- a/Services/UserService.cs +++ b/Services/UserService.cs @@ -1,4 +1,5 @@ using MongoDB.Driver; +using MongoDB.Bson; using QRRapidoApp.Data; using QRRapidoApp.Models; using QRRapidoApp.Models.ViewModels; @@ -610,5 +611,142 @@ namespace QRRapidoApp.Services _logger.LogError(ex, "Error registering anonymous usage"); } } + + public async Task<(string rawKey, string prefix)> GenerateApiKeyAsync(string userId, string keyName = "Default") + { + var rawKey = $"qr_{Guid.NewGuid():N}{Guid.NewGuid():N}"; + var prefix = rawKey.Substring(0, 8); + var hash = ComputeSha256Hash(rawKey); + + var keyConfig = new ApiKeyConfig + { + KeyHash = hash, + Prefix = prefix, + Name = keyName, + CreatedAt = DateTime.UtcNow, + IsActive = true + }; + + var update = Builders.Update.Push(u => u.ApiKeys, keyConfig); + await _context.Users.UpdateOneAsync(u => u.Id == userId, update); + + return (rawKey, prefix); + } + + public async Task RevokeApiKeyAsync(string userId, string prefix) + { + var filter = Builders.Filter.Eq(u => u.Id, userId); + var update = Builders.Update.Set("apiKeys.$[key].isActive", false); + var arrayFilters = new List { + new BsonDocumentArrayFilterDefinition(new BsonDocument("key.prefix", prefix)) + }; + + var result = await _context.Users.UpdateOneAsync(filter, update, new UpdateOptions { ArrayFilters = arrayFilters }); + return result.ModifiedCount > 0; + } + + public async Task GetUserByApiKeyAsync(string rawKey) + { + var hash = ComputeSha256Hash(rawKey); + var user = await _context.Users.Find(u => u.ApiKeys.Any(k => k.KeyHash == hash && k.IsActive)).FirstOrDefaultAsync(); + + if (user != null) + { + // Update last used timestamp (fire and forget) + var update = Builders.Update.Set("apiKeys.$[key].lastUsedAt", DateTime.UtcNow); + var arrayFilters = new List { + new BsonDocumentArrayFilterDefinition(new BsonDocument("key.keyHash", hash)) + }; + _ = _context.Users.UpdateOneAsync(u => u.Id == user.Id, update, new UpdateOptions { ArrayFilters = arrayFilters }); + } + + return user; + } + + public async Task ActivateApiSubscriptionAsync( + string userId, + string stripeSubscriptionId, + ApiPlanTier tier, + DateTime periodEnd, + string stripeCustomerId) + { + try + { + var update = Builders.Update + .Set(u => u.ApiSubscription.Tier, tier) + .Set(u => u.ApiSubscription.Status, "active") + .Set(u => u.ApiSubscription.StripeSubscriptionId, stripeSubscriptionId) + .Set(u => u.ApiSubscription.StripeCustomerId, stripeCustomerId) + .Set(u => u.ApiSubscription.CurrentPeriodEnd, periodEnd) + .Set(u => u.ApiSubscription.ActivatedAt, DateTime.UtcNow) + .Set(u => u.ApiSubscription.CanceledAt, (DateTime?)null); + + await _context.Users.UpdateOneAsync(u => u.Id == userId, update); + _logger.LogInformation("API subscription activated: user={UserId} tier={Tier}", userId, tier); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error activating API subscription for user {UserId}", userId); + } + } + + public async Task GetUserByApiSubscriptionIdAsync(string stripeSubscriptionId) + { + try + { + return await _context.Users + .Find(u => u.ApiSubscription.StripeSubscriptionId == stripeSubscriptionId) + .FirstOrDefaultAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding user by API subscription {SubId}", stripeSubscriptionId); + return null; + } + } + + public async Task UpdateApiSubscriptionStatusAsync( + string stripeSubscriptionId, + string status, + ApiPlanTier? newTier = null, + DateTime? periodEnd = null) + { + try + { + var updateDef = Builders.Update + .Set(u => u.ApiSubscription.Status, status); + + if (newTier.HasValue) + updateDef = updateDef.Set(u => u.ApiSubscription.Tier, newTier.Value); + + if (periodEnd.HasValue) + updateDef = updateDef.Set(u => u.ApiSubscription.CurrentPeriodEnd, periodEnd.Value); + + if (status == "canceled") + updateDef = updateDef.Set(u => u.ApiSubscription.CanceledAt, DateTime.UtcNow); + + await _context.Users.UpdateOneAsync( + u => u.ApiSubscription.StripeSubscriptionId == stripeSubscriptionId, + updateDef); + + _logger.LogInformation("API subscription {SubId} status → {Status}", stripeSubscriptionId, status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating API subscription status {SubId}", stripeSubscriptionId); + } + } + + private string ComputeSha256Hash(string rawData) + { + using (var sha256Hash = System.Security.Cryptography.SHA256.Create()) + { + byte[] bytes = sha256Hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(rawData)); + var builder = new System.Text.StringBuilder(); + for (int i = 0; i < bytes.Length; i++) + builder.Append(bytes[i].ToString("x2")); + return builder.ToString(); + } + } } } \ No newline at end of file diff --git a/Views/DevTutoriais/Article.cshtml b/Views/DevTutoriais/Article.cshtml new file mode 100644 index 0000000..f53a573 --- /dev/null +++ b/Views/DevTutoriais/Article.cshtml @@ -0,0 +1,102 @@ +@model QRRapidoApp.Models.ViewModels.ArticleViewModel +@{ + ViewData["Title"] = Model.Metadata.Title + " — QRRapido Docs"; + Layout = "~/Views/Shared/_Layout.cshtml"; + + var culture = ViewBag.Culture as string ?? "pt-BR"; + var isEs = culture == "es-PY"; + var devBase = isEs ? "/es-PY/Developer" : "/Developer"; + var slug = ViewBag.Slug as string; + + string T(string pt, string es) => isEs ? es : pt; +} + +
+ + + +
+ +
+
+

@Model.Metadata.Title

+

+ @Model.Metadata.ReadingTimeMinutes @T("min de leitura", "min de lectura") +  ·  + @Model.Metadata.Date.ToString("dd/MM/yyyy") +

+

@Model.Metadata.Description

+
+
+ +
+ @Html.Raw(Model.HtmlContent) +
+ +
+ @T("Última atualização:", "Última actualización:") @Model.Metadata.LastMod.ToString("dd/MM/yyyy") +
+
+ + + +
+
+ + diff --git a/Views/DevTutoriais/Index.cshtml b/Views/DevTutoriais/Index.cshtml new file mode 100644 index 0000000..79c9a7d --- /dev/null +++ b/Views/DevTutoriais/Index.cshtml @@ -0,0 +1,64 @@ +@model List +@{ + ViewData["Title"] = "Docs & Tutoriais para Desenvolvedores"; + Layout = "~/Views/Shared/_Layout.cshtml"; + + var culture = ViewBag.Culture as string ?? "pt-BR"; + var isEs = culture == "es-PY"; + var devBase = isEs ? "/es-PY/Developer" : "/Developer"; + + string T(string pt, string es) => isEs ? es : pt; +} + +
+ +
+
+
+
+

@T("Docs & Tutoriais", "Docs & Tutoriales")

+

@T("Guias técnicos para integrar e usar a API QRRapido.", "Guías técnicas para integrar ha usar la API QRRapido.")

+
+
+ +
+ + @if (Model.Any()) + { +
+ @foreach (var article in Model) + { +
+
+
+
@article.Title
+

@article.Description

+
+ + @article.ReadingTimeMinutes @T("min de leitura", "min de lectura") + +
+ + @T("Ler", "Leer") + +
+
+
+ } +
+ } + else + { +
+ +

@T("Nenhum artigo encontrado.", "Ningún artículo encontrado.")

+
+ } +
diff --git a/Views/Developer/Index.cshtml b/Views/Developer/Index.cshtml new file mode 100644 index 0000000..2dadf67 --- /dev/null +++ b/Views/Developer/Index.cshtml @@ -0,0 +1,367 @@ +@model QRRapidoApp.Models.User +@{ + ViewData["Title"] = "Portal do Desenvolvedor"; + Layout = "~/Views/Shared/_Layout.cshtml"; + + var newKey = TempData["NewKey"] as string; + var newKeyName = TempData["NewKeyName"] as string; + var errorMsg = TempData["Error"] as string; + var successMsg = TempData["Success"] as string; + + var activeKeys = Model.ApiKeys.Where(k => k.IsActive).OrderByDescending(k => k.CreatedAt).ToList(); + var revokedKeys = Model.ApiKeys.Where(k => !k.IsActive).OrderByDescending(k => k.CreatedAt).ToList(); + + var baseUrl = Context.Request.Scheme + "://" + Context.Request.Host; + var culture = ViewBag.Culture as string ?? "pt-BR"; + var isEs = culture == "es-PY"; + var docsBase = isEs ? "/es-PY/Developer" : "/Developer"; + + string T(string pt, string es) => isEs ? es : pt; +} + +
+ + +
+
+
+ +
+
+

@T("Portal do Desenvolvedor", "Portal del Desarrollador")

+

+ @T("Gerencie suas chaves de API e integre o QR Rapido nas suas aplicações.", + "Gestioná tus claves de API ha integrá QR Rapido en tus aplicaciones.") + @{ + var tier = Model.ApiSubscription?.EffectiveTier ?? QRRapidoApp.Models.ApiPlanTier.Free; + var tierLabel = tier == QRRapidoApp.Models.ApiPlanTier.Free + ? "Free" + : $"{tier}"; + } + @T("Plano atual:", "Plan actual:") @Html.Raw(tierLabel) +

+
+
+ +
+ + + @if (!string.IsNullOrEmpty(errorMsg)) + { + + } + @if (!string.IsNullOrEmpty(successMsg)) + { + + } + + + @if (!string.IsNullOrEmpty(newKey)) + { + + } + +
+ + +
+ + +
+
+
+ @T("Chaves Ativas", "Claves Activas") + @activeKeys.Count / 5 +
+
+
+ @if (activeKeys.Any()) + { +
+ + + + + + + + + + + + @foreach (var key in activeKeys) + { + + + + + + + + } + +
@T("Nome", "Nombre")@T("Prefixo", "Prefijo")@T("Criada", "Creada")@T("Último uso", "Último uso")
@key.Name@key.Prefix...@key.CreatedAt.ToString("dd/MM/yyyy") + @(key.LastUsedAt.HasValue + ? key.LastUsedAt.Value.ToString("dd/MM/yyyy HH:mm") + : T("Nunca", "Nunca")) + +
+ + +
+
+
+ } + else + { +
+ +

@T("Nenhuma chave ativa. Crie a primeira abaixo.", "Ninguna clave activa. Creá la primera abajo.")

+
+ } +
+
+ + + @if (activeKeys.Count < 5) + { +
+
+
+ @T("Criar Nova Chave", "Crear Nueva Clave") +
+
+
+
+
+ + +
@T("Identifique para qual projeto ou ambiente esta chave será usada.", "Identificá para qué proyecto o ambiente se va a usar esta clave.")
+
+ +
+
+
+ } + else + { +
+ + @T("Você atingiu o limite de 5 chaves ativas. Revogue uma para criar outra.", + "Llegaste al límite de 5 claves activas. Revocá una para crear otra.") +
+ } + + + @if (revokedKeys.Any()) + { +
+
+
+ @T("Chaves Revogadas", "Claves Revocadas") +
+
+
+
+ + + @foreach (var key in revokedKeys) + { + + + + + + } + +
@key.Name@key.Prefix...@T("Revogada", "Revocada")
+
+
+
+ } +
+ + +
+ + +
+
+
Quickstart
+
+
+

+ @T("Envie suas requisições para o endpoint abaixo com o header", + "Enviá tus solicitudes al endpoint de abajo con el header") X-API-Key: +

+
+ POST @baseUrl/api/v1/QRManager/generate +
+ +
@T("Exemplo (cURL)", "Ejemplo (cURL)")
+
+
curl -X POST \
+  @baseUrl/api/v1/QRManager/generate \
+  -H "X-API-Key: @T("SUA_CHAVE_AQUI", "TU_CLAVE_AQUI")" \
+  -H "Content-Type: application/json" \
+  -d '{
+  "content": "https://seusite.com",
+  "type": "url",
+  "size": 400,
+  "primaryColor": "#000000",
+  "backgroundColor": "#FFFFFF"
+}'
+
+ +
@T("Resposta de sucesso", "Respuesta exitosa")
+
+
{
+  "success": true,
+  "qrCodeBase64": "iVBORw0KGgo...",
+  "qrId": "abc123",
+  "generationTimeMs": 280,
+  "remainingCredits": 42,
+  "fromCache": false
+}
+
+ + + @T("Testar Ping", "Probar Ping") + +
+
+ + +
+
+
@T("Limites da API", "Límites de la API")
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
@T("Recurso", "Recurso")@T("Gratuito", "Gratuito")@T("Créditos", "Créditos")
@T("QRs por chave", "QRs por clave")@T("5 vitalícios", "5 de por vida")1 @T("crédito/QR", "crédito/QR")
Rate limit600 req/min @T("por IP", "por IP")
@T("Chaves ativas", "Claves activas")@T("Até 5", "Hasta 5")
@T("Formatos", "Formatos")PNG (base64)
+ +
+
+ + +
+
+
@T("Tipos de QR (campo", "Tipos de QR (campo") type)
+
+
+
    +
  • + urlLink / URL +
  • +
  • + pix@T("Pagamento Pix", "Pago Pix") +
  • +
  • + wifi@T("Rede Wi-Fi", "Red Wi-Fi") +
  • +
  • + vcard@T("Cartão de Visita", "Tarjeta de Visita") +
  • +
  • + whatsappLink WhatsApp +
  • +
  • + emailE-mail +
  • +
  • + smsSMS +
  • +
  • + texto@T("Texto livre", "Texto libre") +
  • +
+
+
+ +
+
+
+ +@section Scripts { + +} diff --git a/Views/Developer/Pricing.cshtml b/Views/Developer/Pricing.cshtml new file mode 100644 index 0000000..b6f6df5 --- /dev/null +++ b/Views/Developer/Pricing.cshtml @@ -0,0 +1,180 @@ +@using QRRapidoApp.Models +@{ + ViewData["Title"] = "Planos de API — QRRapido"; + Layout = "~/Views/Shared/_Layout.cshtml"; + + var currentTier = ViewBag.CurrentTier as ApiPlanTier? ?? ApiPlanTier.Free; + var errorMsg = TempData["Error"] as string; + var culture = ViewBag.Culture as string ?? "pt-BR"; + var isEs = culture == "es-PY"; + var devBase = isEs ? "/es-PY/Developer" : "/Developer"; + + string T(string pt, string es) => isEs ? es : pt; +} + +
+ +
+

@T("Planos de API", "Planes de API")

+

+ @T("Escolha o plano que melhor se adapta à sua integração.", + "Elegí el plan porã que mejor se adapte a tu integración.") +
+ @T("Todos os planos incluem a", "Todos los planes incluyen la") + API REST + @T("com autenticação por chave.", "con autenticación por clave.") +

+ @if (!string.IsNullOrEmpty(errorMsg)) + { +
@errorMsg
+ } +
+ +
+ + +
+
+
+
Free
+
+
+
R$0
+

@T("para sempre", "para siempre")

+
    +
  • @T("5 QRs vitalícios (cota gratuita)", "5 QRs de por vida (cuota gratuita)")
  • +
  • 10 req/min
  • +
  • 500 req/@T("mês", "mes")
  • +
  • PNG @T("e", "ha") WebP
  • +
  • @T("Suporte prioritário", "Soporte prioritario")
  • +
+
+ +
+
+ + +
+
+
+
Starter
+
+
+
R$29
+

@T("por mês", "por mes")

+
    +
  • @T("Créditos inclusos na cota", "Créditos incluidos en la cuota")
  • +
  • 50 req/min
  • +
  • 10.000 req/@T("mês", "mes")
  • +
  • PNG, WebP @T("e", "ha") SVG
  • +
  • @T("Suporte por e-mail", "Soporte por e-mail")
  • +
+
+ +
+
+ + +
+
+
+
Pro
+ @T("Popular", "Popular") +
+
+
R$99
+

@T("por mês", "por mes")

+
    +
  • @T("Créditos inclusos na cota", "Créditos incluidos en la cuota")
  • +
  • 200 req/min
  • +
  • 100.000 req/@T("mês", "mes")
  • +
  • PNG, WebP @T("e", "ha") SVG
  • +
  • @T("Suporte prioritário", "Soporte prioritario")
  • +
+
+ +
+
+ + +
+
+
+
Business
+
+
+
R$299
+

@T("por mês", "por mes")

+
    +
  • @T("Créditos inclusos na cota", "Créditos incluidos en la cuota")
  • +
  • 500 req/min
  • +
  • 500.000 req/@T("mês", "mes")
  • +
  • PNG, WebP @T("e", "ha") SVG
  • +
  • @T("Suporte dedicado + SLA", "Soporte dedicado + SLA")
  • +
+
+ +
+
+
+ + +
+ + @T("A assinatura de API é", "La suscripción de API es") + @T("independente", "independiente") + @T("dos créditos de QR do site. Os planos pagos liberam rate limits maiores para a API. Créditos de QR continuam sendo comprados separadamente.", + "de los créditos de QR del sitio. Los planes pagos liberan rate limits mayores para la API. Los créditos de QR se compran por separado.") +
+ + +
diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 591a12a..61cddc7 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -359,6 +359,9 @@
  • @Localizer["History"]
  • +
  • + Desenvolvedor +
  • diff --git a/Views/_ViewImports.cshtml b/Views/_ViewImports.cshtml new file mode 100644 index 0000000..3c6dc5b --- /dev/null +++ b/Views/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using QRRapidoApp +@using QRRapidoApp.Models +@using QRRapidoApp.Models.ViewModels +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/appsettings.json b/appsettings.json index 49ea4e7..0a925cb 100644 --- a/appsettings.json +++ b/appsettings.json @@ -170,10 +170,6 @@ "IncludeDatabaseSize": true, "TestQuery": true }, - "Seq": { - "TimeoutSeconds": 3, - "TestLogMessage": "QRRapido health check test" - }, "Resources": { "CpuThresholdPercent": 85, "MemoryThresholdMB": 600,