feat: api separada do front-end e area do desenvolvedor.
Some checks failed
Deploy QR Rapido / test (push) Failing after 17s
Deploy QR Rapido / build-and-push (push) Has been skipped
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Has been skipped

This commit is contained in:
Ricardo Carneiro 2026-03-08 12:40:51 -03:00
parent e523ade864
commit 7a0c12f8d2
48 changed files with 3362 additions and 316 deletions

54
.dockerignore Normal file
View File

@ -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

View File

@ -188,6 +188,9 @@ jobs:
--env-add Serilog__OpenSearchUrl="http://141.148.162.114:19201" \ --env-add Serilog__OpenSearchUrl="http://141.148.162.114:19201" \
--env-add Serilog__OpenSearchFallback="http://129.146.116.218:19202" \ --env-add Serilog__OpenSearchFallback="http://129.146.116.218:19202" \
--env-add Admin__AllowedEmails__0="rrcgoncalves@gmail.com" \ --env-add Admin__AllowedEmails__0="rrcgoncalves@gmail.com" \
--update-order start-first \
--update-delay 30s \
--update-parallelism 1 \
--with-registry-auth \ --with-registry-auth \
qrrapido-prod qrrapido-prod
else else
@ -241,10 +244,10 @@ jobs:
# Verifica se os serviços estão respondendo em ambos servidores # Verifica se os serviços estão respondendo em ambos servidores
echo "Verificando Servidor 1..." 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..." 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 # Testa o site principal
echo "Testando site principal..." echo "Testando site principal..."

5
.gitignore vendored
View File

@ -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 ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.
## ##
@ -384,3 +388,4 @@ scripts/secrets.env
*.crt *.crt
*.key *.key
wwwroot/dist/ wwwroot/dist/
.aider*

View File

@ -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 **2535% 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 `<img>` 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
<picture>
<source srcset="data:image/webp;base64,{{ qrCodeBase64 }}" type="image/webp">
<img src="data:image/png;base64,{{ fallbackBase64 }}" alt="Código QR">
</picture>
```
---
## 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 `<img>`
```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 | ~812 KB |
| WebP | ~58 KB |
| SVG | ~36 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** |

View File

@ -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 **2535% 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 `<img>` 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
<picture>
<source srcset="data:image/webp;base64,{{ qrCodeBase64 }}" type="image/webp">
<img src="data:image/png;base64,{{ fallbackBase64 }}" alt="QR Code">
</picture>
```
---
## 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 `<img>`
```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 | ~812 KB |
| WebP | ~58 KB |
| SVG | ~36 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** |

View File

@ -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 (1002000) |
| `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
<img src="data:image/png;base64,{{ qrCodeBase64 }}" alt="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.

View File

@ -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 (1002000) |
| `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
<img src="data:image/png;base64,{{ qrCodeBase64 }}" alt="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.

View File

@ -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.

View File

@ -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**.

View File

@ -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<DevTutoriaisController> _logger;
private const string ContentFolder = "DevTutoriais";
public DevTutoriaisController(IMarkdownService markdownService, ILogger<DevTutoriaisController> logger)
{
_markdownService = markdownService;
_logger = logger;
}
[Route("Developer/docs")]
[Route("es-PY/Developer/docs")]
[Route("es/Developer/docs")]
public async Task<IActionResult> 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<IActionResult> 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";
}
}
}

View File

@ -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<DeveloperController> _logger;
public DeveloperController(
IUserService userService,
StripeService stripeService,
ILogger<DeveloperController> logger)
{
_userService = userService;
_stripeService = stripeService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> SubscribeApi(string planTier)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId)) return Unauthorized();
if (!Enum.TryParse<ApiPlanTier>(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<IActionResult> 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";
}
}
}

View File

@ -243,10 +243,9 @@ namespace QRRapidoApp.Controllers
// Include critical metrics for monitoring // Include critical metrics for monitoring
metrics = new metrics = new
{ {
mongodb_connected = GetCheckStatus(healthReport, "mongodb") != "unhealthy", mongodb_connected = GetCheckStatus(healthReport, "mongodb") == "healthy",
seq_reachable = GetCheckStatus(healthReport, "seq") != "unhealthy", resources_ok = GetCheckStatus(healthReport, "resources") == "healthy",
resources_ok = GetCheckStatus(healthReport, "resources") != "unhealthy", external_services_ok = GetCheckStatus(healthReport, "external_services") == "healthy"
external_services_ok = GetCheckStatus(healthReport, "external_services") != "unhealthy"
} }
}; };

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using QRRapidoApp.Models.ViewModels; using QRRapidoApp.Models.ViewModels;
using QRRapidoApp.Models.DTOs;
using QRRapidoApp.Services; using QRRapidoApp.Services;
using System.Diagnostics; using System.Diagnostics;
using System.Security.Claims; using System.Security.Claims;
@ -15,15 +16,22 @@ namespace QRRapidoApp.Controllers
[Route("api/[controller]")] [Route("api/[controller]")]
public class QRController : ControllerBase public class QRController : ControllerBase
{ {
private readonly IQRCodeService _qrService; private readonly IQRBusinessManager _qrBusinessManager;
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IQRCodeService _qrService; // Mantido para Download
private readonly ILogger<QRController> _logger; private readonly ILogger<QRController> _logger;
private readonly IStringLocalizer<QRRapidoApp.Resources.SharedResource> _localizer; private readonly IStringLocalizer<QRRapidoApp.Resources.SharedResource> _localizer;
public QRController(IQRCodeService qrService, IUserService userService, ILogger<QRController> logger, IStringLocalizer<QRRapidoApp.Resources.SharedResource> localizer) public QRController(
IQRBusinessManager qrBusinessManager,
IUserService userService,
IQRCodeService qrService,
ILogger<QRController> logger,
IStringLocalizer<QRRapidoApp.Resources.SharedResource> localizer)
{ {
_qrService = qrService; _qrBusinessManager = qrBusinessManager;
_userService = userService; _userService = userService;
_qrService = qrService;
_logger = logger; _logger = logger;
_localizer = localizer; _localizer = localizer;
} }
@ -31,144 +39,61 @@ namespace QRRapidoApp.Controllers
[HttpPost("GenerateRapid")] [HttpPost("GenerateRapid")]
public async Task<IActionResult> GenerateRapid([FromBody] QRGenerationRequest request) public async Task<IActionResult> GenerateRapid([FromBody] QRGenerationRequest request)
{ {
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; var context = new UserRequesterContext
// ---------------------------------------------------------
// 1. FLUXO DE ANÔNIMOS (TRAVA HÍBRIDA)
// ---------------------------------------------------------
if (string.IsNullOrEmpty(userId))
{ {
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; UserId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
DeviceId = Request.Cookies["_qr_device_id"]
};
// Gerenciar Cookie de DeviceID var result = await _qrBusinessManager.ProcessGenerationAsync(request, context);
var deviceId = Request.Cookies["_qr_device_id"];
if (string.IsNullOrEmpty(deviceId)) if (!result.Success)
{ {
deviceId = Guid.NewGuid().ToString("N"); if (result.ErrorCode == "LIMIT_REACHED")
Response.Cookies.Append("_qr_device_id", deviceId, new CookieOptions {
return StatusCode(429, new
{
error = result.Message,
upgradeUrl = "/Account/Login"
});
}
if (result.ErrorCode == "INSUFFICIENT_CREDITS")
{
return StatusCode(402, new {
success = false,
error = result.Message,
redirectUrl = "/Pagamento/SelecaoPlano"
});
}
return BadRequest(new { success = false, error = result.Message });
}
// Gerenciar Cookie de DeviceID para anônimos
if (!context.IsAuthenticated && string.IsNullOrEmpty(context.DeviceId))
{
var newDeviceId = Guid.NewGuid().ToString("N");
Response.Cookies.Append("_qr_device_id", newDeviceId, new CookieOptions
{ {
Expires = DateTime.UtcNow.AddYears(1), Expires = DateTime.UtcNow.AddYears(1),
HttpOnly = true, // Protege contra limpeza via JS simples HttpOnly = true,
Secure = true, Secure = true,
SameSite = SameSiteMode.Strict SameSite = SameSiteMode.Strict
}); });
} }
// Verificar Limite (1 por dia) return Ok(new
var canGenerate = await _userService.CheckAnonymousLimitAsync(ipAddress, deviceId);
if (!canGenerate)
{ {
return StatusCode(429, new success = true,
{ QRCodeBase64 = result.QRCodeBase64,
error = "Limite diário atingido (1 QR Grátis). Cadastre-se para ganhar mais 5!", QRId = result.QRId,
upgradeUrl = "/Account/Login" FromCache = result.FromCache,
RemainingQRs = context.IsAuthenticated ? result.RemainingCredits : (result.RemainingFreeQRs > 0 ? result.RemainingFreeQRs : 0),
Message = result.Message,
GenerationTimeMs = result.GenerationTimeMs
}); });
} }
// Gerar QR
request.IsPremium = false;
request.OptimizeForSpeed = true;
var result = await _qrService.GenerateRapidAsync(request);
if (result.Success)
{
// Registrar uso anônimo para bloqueio futuro
await _userService.RegisterAnonymousUsageAsync(ipAddress, deviceId, result.QRId);
}
return Ok(result);
}
// ---------------------------------------------------------
// 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)
{
_logger.LogInformation($"Duplicate QR found for user {userId}. Returning cached version.");
return Ok(new QRGenerationResult
{
Success = true,
QRCodeBase64 = duplicate.QRCodeBase64,
QRId = duplicate.Id,
FromCache = true,
RemainingQRs = user.Credits,
Message = "Recuperado do histórico (sem custo)"
});
}
// 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"
});
}
private async Task<IActionResult> 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<QRCodeHistory>.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")] [HttpGet("GetUserStats")]
public async Task<IActionResult> GetUserStats() public async Task<IActionResult> GetUserStats()
{ {
@ -187,8 +112,6 @@ namespace QRRapidoApp.Controllers
}); });
} }
// --- Endpoints mantidos ---
[HttpGet("Download/{qrId}")] [HttpGet("Download/{qrId}")]
public async Task<IActionResult> Download(string qrId, string format = "png") public async Task<IActionResult> Download(string qrId, string format = "png")
{ {
@ -237,13 +160,6 @@ namespace QRRapidoApp.Controllers
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId)) return Unauthorized(); 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) if (logo != null)
{ {
using var ms = new MemoryStream(); using var ms = new MemoryStream();
@ -252,27 +168,33 @@ namespace QRRapidoApp.Controllers
request.HasLogo = true; request.HasLogo = true;
} }
if (user.FreeQRsUsed < 5) await _userService.IncrementFreeUsageAsync(userId); var context = new UserRequesterContext
else await _userService.DeductCreditAsync(userId); {
UserId = userId,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"
};
request.IsPremium = true; var result = await _qrBusinessManager.ProcessGenerationAsync(request, context);
var result = await _qrService.GenerateRapidAsync(request);
await _userService.SaveQRToHistoryAsync(userId, result); if (!result.Success) return BadRequest(result);
return Ok(result); return Ok(new {
success = true,
QRCodeBase64 = result.QRCodeBase64,
QRId = result.QRId,
RemainingQRs = result.RemainingCredits
});
} }
[HttpPost("SaveToHistory")] [HttpPost("SaveToHistory")]
public async Task<IActionResult> SaveToHistory([FromBody] SaveToHistoryRequest request) public async Task<IActionResult> SaveToHistory([FromBody] SaveToHistoryRequest request)
{ {
// Endpoint legado para compatibilidade com front antigo
return Ok(new { success = true }); return Ok(new { success = true });
} }
} }
public class SaveToHistoryRequest public class SaveToHistoryRequest
{ {
public string QrId { get; set; } public string QrId { get; set; } = string.Empty;
} }
} }

View File

@ -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
{
/// <summary>
/// QRRapido public API v1 — generate QR codes programmatically.
/// Authenticate with your API key using the <c>X-API-Key</c> header.
/// Get your key at <c>https://qrrapido.site/Developer</c>.
/// </summary>
[ApiController]
[Route("api/v1/[controller]")]
[EnableCors("ApiPolicy")]
[ApiKeyAuthorize]
[Produces("application/json")]
public class QRManagerController : ControllerBase
{
private readonly IQRBusinessManager _qrBusinessManager;
private readonly ILogger<QRManagerController> _logger;
private static readonly HashSet<string> _validFormats = new(StringComparer.OrdinalIgnoreCase)
{ "png", "webp", "svg" };
private static readonly HashSet<string> _validTypes = new(StringComparer.OrdinalIgnoreCase)
{ "url", "pix", "wifi", "vcard", "whatsapp", "email", "sms", "texto" };
public QRManagerController(IQRBusinessManager qrBusinessManager, ILogger<QRManagerController> logger)
{
_qrBusinessManager = qrBusinessManager;
_logger = logger;
}
/// <summary>Health check — no API key required.</summary>
/// <response code="200">API is running.</response>
[HttpGet("ping")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Ping() =>
Ok(new { status = "QRRapido API v1 is up", timestamp = DateTime.UtcNow });
/// <summary>
/// Generate a QR code and receive it as a base64-encoded image.
/// </summary>
/// <remarks>
/// 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`.
/// </remarks>
/// <response code="200">QR code generated successfully.</response>
/// <response code="400">Invalid request parameters.</response>
/// <response code="401">Missing or invalid API key.</response>
/// <response code="402">Insufficient credits.</response>
/// <response code="429">Rate limit or monthly quota exceeded.</response>
/// <response code="500">Internal server error.</response>
[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<IActionResult> 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);
}
/// <summary>
/// Smoke-test endpoint — generates a QR code without an API key (GET, for quick browser testing).
/// </summary>
[HttpGet("generate-get-test")]
[AllowAnonymous]
[ApiExplorerSettings(IgnoreApi = true)] // Hidden from Swagger docs
public async Task<IActionResult> 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);
}
}
}

View File

@ -31,13 +31,15 @@ namespace QRRapidoApp.Controllers
// Spanish: /es-PY/tutoriais/{slug} // Spanish: /es-PY/tutoriais/{slug}
[Route("tutoriais/{slug}")] [Route("tutoriais/{slug}")]
[Route("es-PY/tutoriais/{slug}")] [Route("es-PY/tutoriais/{slug}")]
[Route("es/tutoriais/{slug}")]
public async Task<IActionResult> Article(string slug, string? culture = null) public async Task<IActionResult> Article(string slug, string? culture = null)
{ {
try try
{ {
// Determine culture from URL path if not provided // Determine culture from URL path if not provided
culture ??= Request.Path.Value?.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) == true var reqPath = Request.Path.Value ?? "";
? "es-PY" culture ??= reqPath.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) ? "es-PY"
: reqPath.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) ? "es"
: "pt-BR"; : "pt-BR";
var article = await _markdownService.GetArticleAsync(slug, culture); var article = await _markdownService.GetArticleAsync(slug, culture);
@ -89,13 +91,15 @@ namespace QRRapidoApp.Controllers
// Spanish: /es-PY/tutoriais // Spanish: /es-PY/tutoriais
[Route("tutoriais")] [Route("tutoriais")]
[Route("es-PY/tutoriais")] [Route("es-PY/tutoriais")]
[Route("es/tutoriais")]
public async Task<IActionResult> Index(string? culture = null) public async Task<IActionResult> Index(string? culture = null)
{ {
try try
{ {
// Determine culture from URL path if not provided // Determine culture from URL path if not provided
culture ??= Request.Path.Value?.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) == true var reqPath = Request.Path.Value ?? "";
? "es-PY" culture ??= reqPath.StartsWith("/es-PY", StringComparison.OrdinalIgnoreCase) ? "es-PY"
: reqPath.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) ? "es"
: "pt-BR"; : "pt-BR";
var articles = await _markdownService.GetAllArticlesAsync(culture); var articles = await _markdownService.GetAllArticlesAsync(culture);

View File

@ -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<string, System.Collections.Generic.Queue<DateTime>> _abuseTracker = new();
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<ApiKeyAuthorizeAttribute>>();
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<IUserService>();
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<IApiRateLimitService>();
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] + "..." : "***";
/// <summary>Logs a Warning when the same key hits 429 three times within 60 seconds.</summary>
private static void TrackAbuse(string prefix, ILogger logger, string ip)
{
var queue = _abuseTracker.GetOrAdd(prefix, _ => new System.Collections.Generic.Queue<DateTime>());
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);
}
}
}
}
}

View File

@ -0,0 +1,50 @@
namespace QRRapidoApp.Middleware
{
/// <summary>
/// Adds security headers and JSON error handling for /api/v1/* routes.
/// </summary>
public class ApiSecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ApiSecurityHeadersMiddleware> _logger;
public ApiSecurityHeadersMiddleware(RequestDelegate next, ILogger<ApiSecurityHeadersMiddleware> 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
});
}
}
}
}
}

View File

@ -16,7 +16,7 @@ namespace QRRapidoApp.Middleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly ILogger<LanguageRedirectionMiddleware> _logger; private readonly ILogger<LanguageRedirectionMiddleware> _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 DefaultCulture = "pt-BR";
private const string CookieName = ".AspNetCore.Culture"; private const string CookieName = ".AspNetCore.Culture";
@ -28,9 +28,27 @@ namespace QRRapidoApp.Middleware
public async Task InvokeAsync(HttpContext context) 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)) if (IsSpecialRoute(path))
{ {
await _next(context); await _next(context);
@ -58,7 +76,15 @@ namespace QRRapidoApp.Middleware
return; 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)) if (TryHandleCultureAliasOrUnknown(context, firstSegment, segments))
{ {
return; return;
@ -81,8 +107,7 @@ namespace QRRapidoApp.Middleware
private bool TryHandleCultureAliasOrUnknown(HttpContext context, string firstSegment, string[] segments) private bool TryHandleCultureAliasOrUnknown(HttpContext context, string firstSegment, string[] segments)
{ {
// Map known aliases to canonical forms // Map known aliases to canonical forms
if (string.Equals(firstSegment, "es", StringComparison.OrdinalIgnoreCase) || if (string.Equals(firstSegment, "es-py", StringComparison.OrdinalIgnoreCase))
string.Equals(firstSegment, "es-py", StringComparison.OrdinalIgnoreCase))
{ {
return RedirectToLanguage(context, "es-PY", segments); return RedirectToLanguage(context, "es-PY", segments);
} }

36
Models/ApiPlanTier.cs Normal file
View File

@ -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<ApiPlanTier, (int PerMinute, int PerMonth)> _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"
};
}
}

36
Models/ApiSubscription.cs Normal file
View File

@ -0,0 +1,36 @@
using MongoDB.Bson.Serialization.Attributes;
namespace QRRapidoApp.Models
{
/// <summary>
/// Embedded document tracking the user's API subscription (separate from the QR credits plan).
/// </summary>
public class ApiSubscription
{
[BsonElement("tier")]
public ApiPlanTier Tier { get; set; } = ApiPlanTier.Free;
/// <summary>"free" | "active" | "past_due" | "canceled"</summary>
[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;
}
}

View File

@ -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; }
/// <summary>Output image format: "png", "webp" or "svg".</summary>
public string Format { get; set; } = "png";
/// <summary>MIME type of the encoded image (e.g. "image/png", "image/webp").</summary>
public string MimeType { get; set; } = "image/png";
}
}

View File

@ -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);
}
}

View File

@ -69,5 +69,39 @@ namespace QRRapidoApp.Models
[BsonElement("historyHashes")] [BsonElement("historyHashes")]
public List<string> HistoryHashes { get; set; } = new(); // Stores SHA256 hashes of generated content to prevent double charging public List<string> HistoryHashes { get; set; } = new(); // Stores SHA256 hashes of generated content to prevent double charging
[BsonElement("apiKeys")]
public List<ApiKeyConfig> ApiKeys { get; set; } = new();
/// <summary>API subscription plan (separate from QR credits).</summary>
[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;
} }
} }

View File

@ -34,6 +34,12 @@ namespace QRRapidoApp.Models.ViewModels
/// Quando habilitado, o QR code usa URL de redirect para contabilizar leituras /// Quando habilitado, o QR code usa URL de redirect para contabilizar leituras
/// </summary> /// </summary>
public bool EnableTracking { get; set; } = false; public bool EnableTracking { get; set; } = false;
/// <summary>
/// Output image format: "png" (default), "webp", "svg"
/// WebP is recommended for API consumers — smaller file size, same visual quality.
/// </summary>
public string OutputFormat { get; set; } = "png";
} }
public class QRGenerationResult public class QRGenerationResult
@ -50,5 +56,6 @@ namespace QRRapidoApp.Models.ViewModels
public string? Message { get; set; } // Feedback message (e.g. "Recovered from history") 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 LogoReadabilityInfo? ReadabilityInfo { get; set; } // Nova propriedade para análise de legibilidade
public string? TrackingId { get; set; } // Tracking ID for analytics (Premium feature) public string? TrackingId { get; set; } // Tracking ID for analytics (Premium feature)
public string OutputFormat { get; set; } = "png"; // "png", "webp"
} }
} }

View File

@ -29,6 +29,7 @@ using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core;
using AspNetCore.DataProtection.MongoDb; using AspNetCore.DataProtection.MongoDb;
using Microsoft.OpenApi.Models;
// Fix for WSL path issues - disable StaticWebAssets completely // Fix for WSL path issues - disable StaticWebAssets completely
var options = new WebApplicationOptions var options = new WebApplicationOptions
@ -224,6 +225,7 @@ builder.Services.Configure<RequestLocalizationOptions>(options =>
{ {
new CultureInfo("pt-BR"), new CultureInfo("pt-BR"),
new CultureInfo("es-PY"), new CultureInfo("es-PY"),
new CultureInfo("es"),
}; };
options.DefaultRequestCulture = new RequestCulture("pt-BR", "pt-BR"); options.DefaultRequestCulture = new RequestCulture("pt-BR", "pt-BR");
@ -247,6 +249,9 @@ builder.Services.AddScoped<IMarkdownService, MarkdownService>();
builder.Services.AddScoped<AdDisplayService>(); builder.Services.AddScoped<AdDisplayService>();
builder.Services.AddScoped<StripeService>(); builder.Services.AddScoped<StripeService>();
builder.Services.AddScoped<LogoReadabilityAnalyzer>(); builder.Services.AddScoped<LogoReadabilityAnalyzer>();
builder.Services.AddScoped<IQRBusinessManager, QRBusinessManager>();
builder.Services.AddScoped<IQuotaValidator, QuotaValidator>();
builder.Services.AddScoped<IApiRateLimitService, ApiRateLimitService>();
// Background Services // Background Services
builder.Services.AddHostedService<HistoryCleanupService>(); builder.Services.AddHostedService<HistoryCleanupService>();
@ -262,7 +267,9 @@ if (builder.Configuration.GetValue<bool>("ResourceMonitoring:Enabled", true))
// builder.Services.AddHostedService<MongoDbMonitoringService>(); // builder.Services.AddHostedService<MongoDbMonitoringService>();
//} //}
// 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 => builder.Services.AddCors(options =>
{ {
options.AddPolicy("AllowSpecificOrigins", policy => options.AddPolicy("AllowSpecificOrigins", policy =>
@ -271,11 +278,23 @@ builder.Services.AddCors(options =>
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod(); .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 // Health checks with custom implementations
builder.Services.AddScoped<MongoDbHealthCheck>(); builder.Services.AddScoped<MongoDbHealthCheck>();
builder.Services.AddScoped<SeqHealthCheck>();
builder.Services.AddScoped<ResourceHealthCheck>(); builder.Services.AddScoped<ResourceHealthCheck>();
builder.Services.AddScoped<ExternalServicesHealthCheck>(); builder.Services.AddScoped<ExternalServicesHealthCheck>();
@ -309,20 +328,104 @@ builder.Services.Configure<KestrelServerOptions>(options =>
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); 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<string>()
}
});
// 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(); var app = builder.Build();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseForwardedHeaders(); 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 // Configure the HTTP request pipeline
if (!app.Environment.IsDevelopment()) 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.UseHsts();
app.UseHttpsRedirection();
} }
app.UseHttpsRedirection(); // Security headers + JSON exception handler for /api/v1/* routes
app.UseMiddleware<QRRapidoApp.Middleware.ApiSecurityHeadersMiddleware>();
app.UseStaticFiles(); app.UseStaticFiles();
@ -357,15 +460,14 @@ app.MapHealthChecks("/healthcheck");
// pattern: "Account/{action}", // pattern: "Account/{action}",
// defaults: new { controller = "Account" }); // defaults: new { controller = "Account" });
// Language routes (must be first) // Language routes (must be first for WEB)
app.MapControllerRoute( app.MapControllerRoute(
name: "localized", 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( app.MapControllerRoute(
name: "api", name: "api_v1",
pattern: "api/{controller}/{action=Index}/{id?}"); pattern: "api/v1/{controller}/{action}/{id?}");
// Default fallback route (for development/testing without culture) // Default fallback route (for development/testing without culture)
app.MapControllerRoute( app.MapControllerRoute(

View File

@ -6,7 +6,7 @@
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"applicationUrl": "https://0.0.0.0:52428;http://0.0.0.0:52429" "applicationUrl": "https://localhost:52428;http://localhost:52429"
} }
} }
} }

View File

@ -29,6 +29,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Localization" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Localization" Version="2.2.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="xunit.assert" Version="2.9.3" /> <PackageReference Include="xunit.assert" Version="2.9.3" />
<PackageReference Include="xunit.extensibility.core" Version="2.9.3" /> <PackageReference Include="xunit.extensibility.core" Version="2.9.3" />
<PackageReference Include="Markdig" Version="0.37.0" /> <PackageReference Include="Markdig" Version="0.37.0" />
@ -44,11 +45,22 @@
<Folder Include="Tests\" /> <Folder Include="Tests\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Exclude E2E test project files from main project compilation -->
<Compile Remove="Tests.E2E/**/*.cs" />
<Content Remove="Tests.E2E/**" />
<None Remove="Tests.E2E/**" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Content\Tutoriais\*.md"> <Content Include="Content\Tutoriais\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content> </Content>
<Content Include="Content\DevTutoriais\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -5,6 +5,8 @@ VisualStudioVersion = 17.13.35818.85
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QRRapidoApp", "QRRapidoApp.csproj", "{8AF92774-40E8-830E-08B3-67F0A0B91DDE}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QRRapidoApp", "QRRapidoApp.csproj", "{8AF92774-40E8-830E-08B3-67F0A0B91DDE}"
EndProject 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}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RootFolder", "RootFolder", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
.github\workflows\deploy.yml = .github\workflows\deploy.yml .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}.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.ActiveCfg = Release|Any CPU
{8AF92774-40E8-830E-08B3-67F0A0B91DDE}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@ -0,0 +1,88 @@
using Microsoft.Extensions.Caching.Distributed;
using QRRapidoApp.Models;
namespace QRRapidoApp.Services
{
/// <summary>
/// Fixed-window rate limiter backed by IDistributedCache (works with MemoryCache or Redis).
/// Minor race conditions at window boundaries are acceptable for rate limiting purposes.
/// </summary>
public class ApiRateLimitService : IApiRateLimitService
{
private readonly IDistributedCache _cache;
private readonly ILogger<ApiRateLimitService> _logger;
public ApiRateLimitService(IDistributedCache cache, ILogger<ApiRateLimitService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<RateLimitResult> 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
);
}
}
}

View File

@ -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<SeqHealthCheck> _logger;
private readonly HttpClient _httpClient;
private readonly int _timeoutSeconds;
private readonly string _testLogMessage;
public SeqHealthCheck(
IConfiguration configuration,
ILogger<SeqHealthCheck> logger,
IHttpClientFactory httpClientFactory)
{
_configuration = configuration;
_logger = logger;
_httpClient = httpClientFactory.CreateClient();
_timeoutSeconds = configuration.GetValue<int>("HealthChecks:Seq:TimeoutSeconds", 3);
_testLogMessage = configuration.GetValue<string>("HealthChecks:Seq:TestLogMessage", "QRRapido health check test");
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var data = new Dictionary<string, object>();
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}");
}
}
}
}

View File

@ -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
{
/// <summary>
/// Checks the rate limit for the given API key prefix and plan tier.
/// Increments the counter only if the request is allowed.
/// </summary>
Task<RateLimitResult> CheckAndIncrementAsync(string keyPrefix, ApiPlanTier tier);
}
}

View File

@ -5,8 +5,8 @@ namespace QRRapidoApp.Services
{ {
public interface IMarkdownService public interface IMarkdownService
{ {
Task<ArticleViewModel?> GetArticleAsync(string slug, string culture); Task<ArticleViewModel?> GetArticleAsync(string slug, string culture, string contentFolder = "Tutoriais");
Task<List<ArticleMetadata>> GetAllArticlesAsync(string culture); Task<List<ArticleMetadata>> GetAllArticlesAsync(string culture, string contentFolder = "Tutoriais");
Task<List<ArticleMetadata>> GetAllArticlesForSitemapAsync(); Task<List<ArticleMetadata>> GetAllArticlesForSitemapAsync();
} }
} }

View File

@ -0,0 +1,10 @@
using QRRapidoApp.Models.DTOs;
using QRRapidoApp.Models.ViewModels;
namespace QRRapidoApp.Services
{
public interface IQRBusinessManager
{
Task<QRResponseDto> ProcessGenerationAsync(QRGenerationRequest request, UserRequesterContext context);
}
}

View File

@ -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);
}
}

View File

@ -42,5 +42,15 @@ namespace QRRapidoApp.Services
// Anonymous Security // Anonymous Security
Task<bool> CheckAnonymousLimitAsync(string ipAddress, string deviceId); Task<bool> CheckAnonymousLimitAsync(string ipAddress, string deviceId);
Task RegisterAnonymousUsageAsync(string ipAddress, string deviceId, string qrId); Task RegisterAnonymousUsageAsync(string ipAddress, string deviceId, string qrId);
// API Key Management
Task<(string rawKey, string prefix)> GenerateApiKeyAsync(string userId, string keyName = "Default");
Task<bool> RevokeApiKeyAsync(string userId, string prefix);
Task<User?> GetUserByApiKeyAsync(string rawKey);
// API Subscription Management
Task ActivateApiSubscriptionAsync(string userId, string stripeSubscriptionId, ApiPlanTier tier, DateTime periodEnd, string stripeCustomerId);
Task<User?> GetUserByApiSubscriptionIdAsync(string stripeSubscriptionId);
Task UpdateApiSubscriptionStatusAsync(string stripeSubscriptionId, string status, ApiPlanTier? newTier = null, DateTime? periodEnd = null);
} }
} }

View File

@ -38,9 +38,9 @@ namespace QRRapidoApp.Services
.Build(); .Build();
} }
public async Task<ArticleViewModel?> GetArticleAsync(string slug, string culture) public async Task<ArticleViewModel?> 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 // Try get from cache
if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle)) if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle))
@ -51,7 +51,7 @@ namespace QRRapidoApp.Services
try try
{ {
var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais"); var contentPath = Path.Combine(_env.ContentRootPath, "Content", contentFolder);
var fileName = $"{slug}.{culture}.md"; var fileName = $"{slug}.{culture}.md";
var filePath = Path.Combine(contentPath, fileName); var filePath = Path.Combine(contentPath, fileName);
@ -91,9 +91,9 @@ namespace QRRapidoApp.Services
} }
} }
public async Task<List<ArticleMetadata>> GetAllArticlesAsync(string culture) public async Task<List<ArticleMetadata>> GetAllArticlesAsync(string culture, string contentFolder = "Tutoriais")
{ {
var cacheKey = $"{CACHE_KEY_ALL}{culture}"; var cacheKey = $"{CACHE_KEY_ALL}{contentFolder}_{culture}";
// Try get from cache // Try get from cache
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cachedList)) if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cachedList))
@ -105,7 +105,7 @@ namespace QRRapidoApp.Services
try try
{ {
var articles = new List<ArticleMetadata>(); var articles = new List<ArticleMetadata>();
var contentPath = Path.Combine(_env.ContentRootPath, "Content", "Tutoriais"); var contentPath = Path.Combine(_env.ContentRootPath, "Content", contentFolder);
if (!Directory.Exists(contentPath)) if (!Directory.Exists(contentPath))
{ {

View File

@ -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<QRBusinessManager> _logger;
public QRBusinessManager(
IQRCodeService qrService,
IQuotaValidator quotaValidator,
IUserService userService,
ILogger<QRBusinessManager> logger)
{
_qrService = qrService;
_quotaValidator = quotaValidator;
_userService = userService;
_logger = logger;
}
public async Task<QRResponseDto> 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();
}
}
}
}

View File

@ -5,6 +5,7 @@ using System.Diagnostics;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Drawing.Processing;
using System.Numerics; using System.Numerics;
@ -96,7 +97,16 @@ namespace QRRapidoApp.Services
// Optimized generation // Optimized generation
var qrCode = await GenerateQRCodeOptimizedAsync(request); 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 // Análise de legibilidade do logo
var readabilityInfo = await _readabilityAnalyzer.AnalyzeAsync(request, request.Size); var readabilityInfo = await _readabilityAnalyzer.AnalyzeAsync(request, request.Size);
@ -104,6 +114,7 @@ namespace QRRapidoApp.Services
var result = new QRGenerationResult var result = new QRGenerationResult
{ {
QRCodeBase64 = base64, QRCodeBase64 = base64,
OutputFormat = outputFormat,
QRId = Guid.NewGuid().ToString(), QRId = Guid.NewGuid().ToString(),
GenerationTimeMs = stopwatch.ElapsedMilliseconds, GenerationTimeMs = stopwatch.ElapsedMilliseconds,
FromCache = false, FromCache = false,
@ -368,7 +379,8 @@ namespace QRRapidoApp.Services
_logger.LogDebug("Using 'no_logo' hash for request without logo"); _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(); using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(keyData)); var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(keyData));
@ -378,6 +390,17 @@ namespace QRRapidoApp.Services
return cacheKey; return cacheKey;
} }
private async Task<byte[]> 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<byte[]> ConvertToSvgAsync(string qrCodeBase64) public async Task<byte[]> ConvertToSvgAsync(string qrCodeBase64)
{ {
return await Task.Run(() => return await Task.Run(() =>

View File

@ -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<QuotaValidator> _logger;
public QuotaValidator(IUserService userService, ILogger<QuotaValidator> 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");
}
}
}
}

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Generic; using System.Collections.Generic;
using System; using System;
using System.Linq;
namespace QRRapidoApp.Services namespace QRRapidoApp.Services
{ {
@ -47,6 +48,86 @@ namespace QRRapidoApp.Services
return session.Url; return session.Url;
} }
/// <summary>
/// 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.
/// </summary>
public async Task<string> 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<string> { "card" },
Mode = "subscription",
LineItems = new List<SessionLineItemOptions>
{
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<string, string>
{
{ "type", "api_subscription" },
{ "planTier", planTier.ToString() },
{ "userId", userId }
},
// Metadata forwarded to the Subscription object (visible on subscription events)
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new Dictionary<string, string>
{
{ "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) public async Task HandleWebhookAsync(string json, string signature)
{ {
var webhookSecret = _config["Stripe:WebhookSecret"]; var webhookSecret = _config["Stripe:WebhookSecret"];
@ -59,12 +140,20 @@ namespace QRRapidoApp.Services
case "checkout.session.completed": case "checkout.session.completed":
if (stripeEvent.Data.Object is Session session) 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) // 1. Handle One-Time Payment (Credits)
if (session.Mode == "payment" && session.PaymentStatus == "paid") else if (session.Mode == "payment" && session.PaymentStatus == "paid")
{ {
await ProcessCreditPayment(session); await ProcessCreditPayment(session);
} }
// 2. Handle Subscription (Legacy) // 2. Handle Subscription (Legacy QR premium)
else if (session.SubscriptionId != null) else if (session.SubscriptionId != null)
{ {
var subscriptionService = new SubscriptionService(); var subscriptionService = new SubscriptionService();
@ -100,15 +189,137 @@ namespace QRRapidoApp.Services
} }
break; break;
case "customer.subscription.deleted": case "customer.subscription.updated":
if (stripeEvent.Data.Object is Subscription deletedSubscription) 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; 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<ApiPlanTier>(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<ApiPlanTier>(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) private async Task ProcessCreditPayment(Session session)
{ {
if (session.Metadata != null && if (session.Metadata != null &&

View File

@ -1,4 +1,5 @@
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Bson;
using QRRapidoApp.Data; using QRRapidoApp.Data;
using QRRapidoApp.Models; using QRRapidoApp.Models;
using QRRapidoApp.Models.ViewModels; using QRRapidoApp.Models.ViewModels;
@ -610,5 +611,142 @@ namespace QRRapidoApp.Services
_logger.LogError(ex, "Error registering anonymous usage"); _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<User>.Update.Push(u => u.ApiKeys, keyConfig);
await _context.Users.UpdateOneAsync(u => u.Id == userId, update);
return (rawKey, prefix);
}
public async Task<bool> RevokeApiKeyAsync(string userId, string prefix)
{
var filter = Builders<User>.Filter.Eq(u => u.Id, userId);
var update = Builders<User>.Update.Set("apiKeys.$[key].isActive", false);
var arrayFilters = new List<ArrayFilterDefinition> {
new BsonDocumentArrayFilterDefinition<BsonDocument>(new BsonDocument("key.prefix", prefix))
};
var result = await _context.Users.UpdateOneAsync(filter, update, new UpdateOptions { ArrayFilters = arrayFilters });
return result.ModifiedCount > 0;
}
public async Task<User?> 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<User>.Update.Set("apiKeys.$[key].lastUsedAt", DateTime.UtcNow);
var arrayFilters = new List<ArrayFilterDefinition> {
new BsonDocumentArrayFilterDefinition<BsonDocument>(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<User>.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<User?> 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<User>.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();
}
}
} }
} }

View File

@ -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;
}
<div class="container mt-4 mb-5">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@devBase">@T("Portal do Desenvolvedor", "Portal del Desarrollador")</a></li>
<li class="breadcrumb-item"><a href="@devBase/docs">Docs</a></li>
<li class="breadcrumb-item active" aria-current="page">@Model.Metadata.Title</li>
</ol>
</nav>
<div class="row">
<article class="col-lg-8">
<header class="mb-4">
<h1 class="mb-3">@Model.Metadata.Title</h1>
<p class="text-muted small">
<i class="fas fa-clock me-1"></i> @Model.Metadata.ReadingTimeMinutes @T("min de leitura", "min de lectura")
&nbsp;·&nbsp;
<i class="fas fa-calendar me-1"></i> @Model.Metadata.Date.ToString("dd/MM/yyyy")
</p>
<p class="lead text-muted">@Model.Metadata.Description</p>
<hr>
</header>
<div class="article-content">
@Html.Raw(Model.HtmlContent)
</div>
<footer class="mt-5 pt-3 border-top">
<small class="text-muted">@T("Última atualização:", "Última actualización:") @Model.Metadata.LastMod.ToString("dd/MM/yyyy")</small>
</footer>
</article>
<aside class="col-lg-4">
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-primary text-white py-2">
<h6 class="mb-0"><i class="fas fa-rocket me-2"></i>@T("Links Rápidos", "Links Pya'e")</h6>
</div>
<div class="list-group list-group-flush">
<a href="@devBase" class="list-group-item list-group-item-action small">
<i class="fas fa-key me-2 text-primary"></i>@T("Minhas Chaves de API", "Mis Claves de API")
</a>
<a href="/api/docs" target="_blank" class="list-group-item list-group-item-action small">
<i class="fas fa-code me-2 text-success"></i>Swagger / OpenAPI
</a>
<a href="@devBase/Pricing" class="list-group-item list-group-item-action small">
<i class="fas fa-arrow-up me-2 text-warning"></i>@T("Planos de API", "Planes de API")
</a>
<a href="@devBase/docs" class="list-group-item list-group-item-action small">
<i class="fas fa-book me-2 text-info"></i>@T("Todos os Tutoriais", "Todos los Tutoriales")
</a>
</div>
</div>
@if (Model.RelatedArticles.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-2">
<h6 class="mb-0"><i class="fas fa-bookmark me-2 text-secondary"></i>@T("Outros Tutoriais", "Otros Tutoriales")</h6>
</div>
<div class="list-group list-group-flush">
@foreach (var related in Model.RelatedArticles)
{
<a href="@devBase/docs/@related.Slug" class="list-group-item list-group-item-action">
<div class="fw-semibold small">@related.Title</div>
<div class="text-muted" style="font-size:0.8rem;">@related.Description</div>
</a>
}
</div>
</div>
}
</aside>
</div>
</div>
<style>
.article-content { font-size: 1.05rem; line-height: 1.8; }
.article-content h2 { margin-top: 2rem; margin-bottom: 0.75rem; font-weight: 600; border-bottom: 1px solid #dee2e6; padding-bottom: 0.3rem; }
.article-content h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }
.article-content pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem 1.25rem; border-radius: 6px; overflow-x: auto; font-size: 0.875rem; }
.article-content code { background: #f4f4f4; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.875em; color: #d63384; }
.article-content pre code { background: transparent; color: inherit; padding: 0; }
.article-content blockquote { border-left: 4px solid #0d6efd; padding: 0.5rem 1rem; margin-left: 0; background: #f8f9ff; border-radius: 0 4px 4px 0; color: #495057; }
.article-content table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
.article-content th, .article-content td { border: 1px solid #dee2e6; padding: 0.5rem 0.75rem; }
.article-content th { background: #f8f9fa; font-weight: 600; }
</style>

View File

@ -0,0 +1,64 @@
@model List<QRRapidoApp.Models.ArticleMetadata>
@{
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;
}
<div class="container mt-4 mb-5">
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<div class="d-flex align-items-center">
<div class="me-3"><i class="fas fa-book-open fa-2x text-primary"></i></div>
<div>
<h1 class="h3 mb-0">@T("Docs & Tutoriais", "Docs & Tutoriales")</h1>
<p class="text-muted mb-0 small">@T("Guias técnicos para integrar e usar a API QRRapido.", "Guías técnicas para integrar ha usar la API QRRapido.")</p>
</div>
</div>
<div class="d-flex gap-2">
<a href="@devBase" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-key me-1"></i> @T("Minhas Chaves", "Mis Claves")
</a>
<a href="/api/docs" target="_blank" class="btn btn-outline-success btn-sm">
<i class="fas fa-code me-1"></i> Swagger
</a>
</div>
</div>
@if (Model.Any())
{
<div class="row g-4">
@foreach (var article in Model)
{
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body d-flex flex-column">
<h5 class="card-title">@article.Title</h5>
<p class="card-text text-muted flex-grow-1 small">@article.Description</p>
<div class="mb-3">
<small class="text-muted">
<i class="fas fa-clock me-1"></i> @article.ReadingTimeMinutes @T("min de leitura", "min de lectura")
</small>
</div>
<a href="@devBase/docs/@article.Slug" class="btn btn-primary btn-sm">
@T("Ler", "Leer") <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="text-center py-5">
<i class="fas fa-book fa-3x text-muted opacity-25 mb-3"></i>
<p class="text-muted">@T("Nenhum artigo encontrado.", "Ningún artículo encontrado.")</p>
</div>
}
</div>

View File

@ -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;
}
<div class="container mt-4 mb-5">
<!-- Cabeçalho -->
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-2">
<div class="d-flex align-items-center">
<div class="me-3">
<i class="fas fa-code fa-2x text-primary"></i>
</div>
<div>
<h1 class="h3 mb-0">@T("Portal do Desenvolvedor", "Portal del Desarrollador")</h1>
<p class="text-muted mb-0 small">
@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
? "<span class='badge bg-secondary ms-2'>Free</span>"
: $"<span class='badge bg-primary ms-2'>{tier}</span>";
}
@T("Plano atual:", "Plan actual:") @Html.Raw(tierLabel)
</p>
</div>
</div>
<div class="d-flex gap-2">
<a href="@docsBase/docs" class="btn btn-outline-info btn-sm">
<i class="fas fa-book me-1"></i> @T("Tutoriais", "Tutoriales")
</a>
<a href="/api/docs" target="_blank" class="btn btn-outline-success btn-sm">
<i class="fas fa-code me-1"></i> Swagger
</a>
<a href="@docsBase/Pricing" class="btn btn-outline-primary btn-sm">
<i class="fas fa-arrow-up me-1"></i> @T("Ver Planos de API", "Ver Planes de API")
</a>
</div>
</div>
<!-- Alertas -->
@if (!string.IsNullOrEmpty(errorMsg))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-circle me-2"></i>@errorMsg
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(successMsg))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle me-2"></i>@successMsg
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Alerta de nova chave gerada (exibida UMA vez) -->
@if (!string.IsNullOrEmpty(newKey))
{
<div class="alert alert-warning border-warning shadow-sm" role="alert">
<h5 class="alert-heading">
<i class="fas fa-key me-2"></i>@T("Chave", "Clave") "<strong>@newKeyName</strong>" @T("criada com sucesso!", "¡creada porã!")
</h5>
<p class="mb-2 small">
<strong class="text-danger">@T("Copie agora.", "Copiá ko'aga.")</strong>
@T("Esta chave", "Esta clave") <strong>@T("não será exibida novamente", "no se mostrará de nuevo")</strong> @T("por segurança.", "por seguridad.")
</p>
<div class="input-group">
<input type="text" id="newApiKey" class="form-control font-monospace" value="@newKey" readonly>
<button class="btn btn-warning" type="button" onclick="copyKey('newApiKey', this)">
<i class="fas fa-copy me-1"></i> @T("Copiar", "Copiar")
</button>
</div>
</div>
}
<div class="row g-4">
<!-- Coluna esquerda: chaves + criar -->
<div class="col-lg-7">
<!-- Minhas Chaves Ativas -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0">
<i class="fas fa-key me-2 text-primary"></i>@T("Chaves Ativas", "Claves Activas")
<span class="badge bg-primary ms-1">@activeKeys.Count / 5</span>
</h5>
</div>
<div class="card-body p-0">
@if (activeKeys.Any())
{
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>@T("Nome", "Nombre")</th>
<th>@T("Prefixo", "Prefijo")</th>
<th>@T("Criada", "Creada")</th>
<th>@T("Último uso", "Último uso")</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var key in activeKeys)
{
<tr>
<td class="fw-semibold">@key.Name</td>
<td><code class="text-primary">@key.Prefix...</code></td>
<td class="small text-muted">@key.CreatedAt.ToString("dd/MM/yyyy")</td>
<td class="small text-muted">
@(key.LastUsedAt.HasValue
? key.LastUsedAt.Value.ToString("dd/MM/yyyy HH:mm")
: T("Nunca", "Nunca"))
</td>
<td class="text-end">
<form method="post" asp-action="RevokeKey" asp-controller="Developer"
onsubmit="return confirm('@T("Revogar", "Revocar") \'@key.Name\'?')">
<input type="hidden" name="prefix" value="@key.Prefix">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fas fa-ban me-1"></i>@T("Revogar", "Revocar")
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-5">
<i class="fas fa-key fa-3x text-muted opacity-25 mb-3"></i>
<p class="text-muted">@T("Nenhuma chave ativa. Crie a primeira abaixo.", "Ninguna clave activa. Creá la primera abajo.")</p>
</div>
}
</div>
</div>
<!-- Criar nova chave -->
@if (activeKeys.Count < 5)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h5 class="mb-0">
<i class="fas fa-plus-circle me-2 text-success"></i>@T("Criar Nova Chave", "Crear Nueva Clave")
</h5>
</div>
<div class="card-body">
<form method="post" asp-action="GenerateKey" asp-controller="Developer">
<div class="mb-3">
<label for="keyName" class="form-label fw-semibold">@T("Nome da chave", "Nombre de la clave")</label>
<input type="text" id="keyName" name="keyName" class="form-control"
placeholder="@T("Ex: Meu App, Produção, Teste...", "Ej: Mi App, Producción, Prueba...")"
maxlength="50" required>
<div class="form-text">@T("Identifique para qual projeto ou ambiente esta chave será usada.", "Identificá para qué proyecto o ambiente se va a usar esta clave.")</div>
</div>
<button type="submit" class="btn btn-success">
<i class="fas fa-key me-2"></i>@T("Gerar Chave de API", "Generar Clave de API")
</button>
</form>
</div>
</div>
}
else
{
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
@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.")
</div>
}
<!-- Chaves revogadas -->
@if (revokedKeys.Any())
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="mb-0 text-muted">
<i class="fas fa-ban me-2"></i>@T("Chaves Revogadas", "Claves Revocadas")
</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle text-muted">
<tbody>
@foreach (var key in revokedKeys)
{
<tr>
<td class="ps-3"><s>@key.Name</s></td>
<td><code>@key.Prefix...</code></td>
<td class="small pe-3">@T("Revogada", "Revocada")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
<!-- Coluna direita: documentação rápida -->
<div class="col-lg-5">
<!-- Quickstart -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-primary text-white py-3">
<h5 class="mb-0"><i class="fas fa-rocket me-2"></i>Quickstart</h5>
</div>
<div class="card-body">
<p class="small text-muted mb-3">
@T("Envie suas requisições para o endpoint abaixo com o header",
"Enviá tus solicitudes al endpoint de abajo con el header") <code>X-API-Key</code>:
</p>
<div class="bg-dark rounded p-3 mb-3">
<code class="text-success small">POST @baseUrl/api/v1/QRManager/generate</code>
</div>
<h6 class="fw-bold mt-3">@T("Exemplo (cURL)", "Ejemplo (cURL)")</h6>
<div class="bg-dark rounded p-3 overflow-auto" style="max-height: 220px;">
<pre class="text-light mb-0 small"><code>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"
}'</code></pre>
</div>
<h6 class="fw-bold mt-3">@T("Resposta de sucesso", "Respuesta exitosa")</h6>
<div class="bg-dark rounded p-3 overflow-auto" style="max-height: 160px;">
<pre class="text-light mb-0 small"><code>{
"success": true,
"qrCodeBase64": "iVBORw0KGgo...",
"qrId": "abc123",
"generationTimeMs": 280,
"remainingCredits": 42,
"fromCache": false
}</code></pre>
</div>
<a href="/api/v1/QRManager/ping" target="_blank" class="btn btn-outline-primary btn-sm mt-3">
<i class="fas fa-heartbeat me-1"></i> @T("Testar Ping", "Probar Ping")
</a>
</div>
</div>
<!-- Limites e planos -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h5 class="mb-0"><i class="fas fa-tachometer-alt me-2 text-warning"></i>@T("Limites da API", "Límites de la API")</h5>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>@T("Recurso", "Recurso")</th>
<th>@T("Gratuito", "Gratuito")</th>
<th>@T("Créditos", "Créditos")</th>
</tr>
</thead>
<tbody>
<tr>
<td>@T("QRs por chave", "QRs por clave")</td>
<td>@T("5 vitalícios", "5 de por vida")</td>
<td>1 @T("crédito/QR", "crédito/QR")</td>
</tr>
<tr>
<td>Rate limit</td>
<td colspan="2">600 req/min @T("por IP", "por IP")</td>
</tr>
<tr>
<td>@T("Chaves ativas", "Claves activas")</td>
<td colspan="2">@T("Até 5", "Hasta 5")</td>
</tr>
<tr>
<td>@T("Formatos", "Formatos")</td>
<td colspan="2">PNG (base64)</td>
</tr>
</tbody>
</table>
<div class="mt-3">
<a href="/Pagamento/SelecaoPlano" class="btn btn-warning btn-sm fw-bold">
<i class="fas fa-coins me-1"></i> @T("Comprar Créditos", "Comprar Créditos")
</a>
</div>
</div>
</div>
<!-- Tipos de QR suportados -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3">
<h5 class="mb-0"><i class="fas fa-list me-2 text-info"></i>@T("Tipos de QR (campo", "Tipos de QR (campo") <code>type</code>)</h5>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush small">
<li class="list-group-item d-flex justify-content-between">
<code>url</code><span class="text-muted">Link / URL</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<code>pix</code><span class="text-muted">@T("Pagamento Pix", "Pago Pix")</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<code>wifi</code><span class="text-muted">@T("Rede Wi-Fi", "Red Wi-Fi")</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<code>vcard</code><span class="text-muted">@T("Cartão de Visita", "Tarjeta de Visita")</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<code>whatsapp</code><span class="text-muted">Link WhatsApp</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<code>email</code><span class="text-muted">E-mail</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<code>sms</code><span class="text-muted">SMS</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<code>texto</code><span class="text-muted">@T("Texto livre", "Texto libre")</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function copyKey(inputId, btn) {
const input = document.getElementById(inputId);
navigator.clipboard.writeText(input.value).then(() => {
const original = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check me-1"></i> @T("Copiado!", "¡Copiado!")';
btn.classList.replace('btn-warning', 'btn-success');
setTimeout(() => {
btn.innerHTML = original;
btn.classList.replace('btn-success', 'btn-warning');
}, 2500);
});
}
</script>
}

View File

@ -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;
}
<div class="container mt-4 mb-5">
<div class="text-center mb-5">
<h1 class="h2 fw-bold">@T("Planos de API", "Planes de API")</h1>
<p class="text-muted">
@T("Escolha o plano que melhor se adapta à sua integração.",
"Elegí el plan porã que mejor se adapte a tu integración.")
<br>
@T("Todos os planos incluem a", "Todos los planes incluyen la")
<strong>API REST</strong>
@T("com autenticação por chave.", "con autenticación por clave.")
</p>
@if (!string.IsNullOrEmpty(errorMsg))
{
<div class="alert alert-danger d-inline-block mt-2">@errorMsg</div>
}
</div>
<div class="row g-4 justify-content-center">
<!-- Free -->
<div class="col-md-6 col-lg-3">
<div class="card h-100 border-0 shadow-sm @(currentTier == ApiPlanTier.Free ? "border border-secondary border-2" : "")">
<div class="card-header bg-secondary text-white text-center py-3">
<h5 class="mb-0">Free</h5>
</div>
<div class="card-body text-center">
<div class="display-5 fw-bold my-3">R$0</div>
<p class="text-muted small">@T("para sempre", "para siempre")</p>
<ul class="list-unstyled text-start small mt-3">
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>@T("5 QRs vitalícios (cota gratuita)", "5 QRs de por vida (cuota gratuita)")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>10 req/min</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>500 req/@T("mês", "mes")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>PNG @T("e", "ha") WebP</li>
<li class="mb-2 text-muted"><i class="fas fa-times me-2"></i>@T("Suporte prioritário", "Soporte prioritario")</li>
</ul>
</div>
<div class="card-footer bg-white text-center pb-4">
@if (currentTier == ApiPlanTier.Free)
{
<span class="badge bg-secondary px-3 py-2">@T("Plano atual", "Plan actual")</span>
}
else
{
<span class="text-muted small">@T("Plano gratuito", "Plan gratuito")</span>
}
</div>
</div>
</div>
<!-- Starter -->
<div class="col-md-6 col-lg-3">
<div class="card h-100 border-0 shadow-sm @(currentTier == ApiPlanTier.Starter ? "border border-primary border-2" : "")">
<div class="card-header bg-primary text-white text-center py-3">
<h5 class="mb-0">Starter</h5>
</div>
<div class="card-body text-center">
<div class="display-5 fw-bold my-3">R$29</div>
<p class="text-muted small">@T("por mês", "por mes")</p>
<ul class="list-unstyled text-start small mt-3">
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>@T("Créditos inclusos na cota", "Créditos incluidos en la cuota")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>50 req/min</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>10.000 req/@T("mês", "mes")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>PNG, WebP @T("e", "ha") SVG</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>@T("Suporte por e-mail", "Soporte por e-mail")</li>
</ul>
</div>
<div class="card-footer bg-white text-center pb-4">
@if (currentTier == ApiPlanTier.Starter)
{
<span class="badge bg-primary px-3 py-2">@T("Plano atual", "Plan actual")</span>
}
else
{
<form method="post" asp-action="SubscribeApi" asp-controller="Developer">
<input type="hidden" name="planTier" value="Starter">
<button class="btn btn-primary w-100" type="submit">@T("Assinar Starter", "Suscribir Starter")</button>
</form>
}
</div>
</div>
</div>
<!-- Pro -->
<div class="col-md-6 col-lg-3">
<div class="card h-100 border-0 shadow @(currentTier == ApiPlanTier.Pro ? "border border-warning border-2" : "border border-warning")">
<div class="card-header bg-warning text-dark text-center py-3 position-relative">
<h5 class="mb-0">Pro</h5>
<span class="position-absolute top-0 end-0 translate-middle badge bg-danger" style="font-size:.6rem;">@T("Popular", "Popular")</span>
</div>
<div class="card-body text-center">
<div class="display-5 fw-bold my-3">R$99</div>
<p class="text-muted small">@T("por mês", "por mes")</p>
<ul class="list-unstyled text-start small mt-3">
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>@T("Créditos inclusos na cota", "Créditos incluidos en la cuota")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>200 req/min</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>100.000 req/@T("mês", "mes")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>PNG, WebP @T("e", "ha") SVG</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>@T("Suporte prioritário", "Soporte prioritario")</li>
</ul>
</div>
<div class="card-footer bg-white text-center pb-4">
@if (currentTier == ApiPlanTier.Pro)
{
<span class="badge bg-warning text-dark px-3 py-2">@T("Plano atual", "Plan actual")</span>
}
else
{
<form method="post" asp-action="SubscribeApi" asp-controller="Developer">
<input type="hidden" name="planTier" value="Pro">
<button class="btn btn-warning fw-bold w-100" type="submit">@T("Assinar Pro", "Suscribir Pro")</button>
</form>
}
</div>
</div>
</div>
<!-- Business -->
<div class="col-md-6 col-lg-3">
<div class="card h-100 border-0 shadow-sm @(currentTier == ApiPlanTier.Business ? "border border-success border-2" : "")">
<div class="card-header bg-success text-white text-center py-3">
<h5 class="mb-0">Business</h5>
</div>
<div class="card-body text-center">
<div class="display-5 fw-bold my-3">R$299</div>
<p class="text-muted small">@T("por mês", "por mes")</p>
<ul class="list-unstyled text-start small mt-3">
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>@T("Créditos inclusos na cota", "Créditos incluidos en la cuota")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>500 req/min</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>500.000 req/@T("mês", "mes")</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>PNG, WebP @T("e", "ha") SVG</li>
<li class="mb-2"><i class="fas fa-check text-success me-2"></i>@T("Suporte dedicado + SLA", "Soporte dedicado + SLA")</li>
</ul>
</div>
<div class="card-footer bg-white text-center pb-4">
@if (currentTier == ApiPlanTier.Business)
{
<span class="badge bg-success px-3 py-2">@T("Plano atual", "Plan actual")</span>
}
else
{
<form method="post" asp-action="SubscribeApi" asp-controller="Developer">
<input type="hidden" name="planTier" value="Business">
<button class="btn btn-success w-100" type="submit">@T("Assinar Business", "Suscribir Business")</button>
</form>
}
</div>
</div>
</div>
</div>
<!-- Nota de créditos separados -->
<div class="alert alert-info mt-4 text-center">
<i class="fas fa-info-circle me-2"></i>
@T("A assinatura de API é", "La suscripción de API es")
<strong>@T("independente", "independiente")</strong>
@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.")
</div>
<div class="text-center mt-3">
<a href="@devBase" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>@T("Voltar ao Portal", "Volver al Portal")
</a>
</div>
</div>

View File

@ -359,6 +359,9 @@
<li><a class="dropdown-item" href="/Account/History"> <li><a class="dropdown-item" href="/Account/History">
<i class="fas fa-history me-2"></i> @Localizer["History"] <i class="fas fa-history me-2"></i> @Localizer["History"]
</a></li> </a></li>
<li><a class="dropdown-item" href="/Developer">
<i class="fas fa-code me-2"></i> Desenvolvedor
</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li> <li>
<form method="post" action="/Account/Logout" class="d-inline"> <form method="post" action="/Account/Logout" class="d-inline">

View File

@ -0,0 +1,4 @@
@using QRRapidoApp
@using QRRapidoApp.Models
@using QRRapidoApp.Models.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -170,10 +170,6 @@
"IncludeDatabaseSize": true, "IncludeDatabaseSize": true,
"TestQuery": true "TestQuery": true
}, },
"Seq": {
"TimeoutSeconds": 3,
"TestLogMessage": "QRRapido health check test"
},
"Resources": { "Resources": {
"CpuThresholdPercent": 85, "CpuThresholdPercent": 85,
"MemoryThresholdMB": 600, "MemoryThresholdMB": 600,