feat: api separada do front-end e area do desenvolvedor.
This commit is contained in:
parent
e523ade864
commit
7a0c12f8d2
54
.dockerignore
Normal file
54
.dockerignore
Normal 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
|
||||||
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@ -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
5
.gitignore
vendored
@ -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*
|
||||||
|
|||||||
129
Content/DevTutoriais/formatos-de-imagem.es-PY.md
Normal file
129
Content/DevTutoriais/formatos-de-imagem.es-PY.md
Normal 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 **25–35% menos tamaño de archivo**.
|
||||||
|
|
||||||
|
**Ventajas:**
|
||||||
|
- Carga más rápida en páginas web
|
||||||
|
- Menor uso de ancho de banda en APIs con alto volumen
|
||||||
|
- Soporte nativo en todos los browsers modernos (Chrome, Safari 14+, Firefox, Edge)
|
||||||
|
- Menor consumo de almacenamiento en la nube (S3, GCS, etc.)
|
||||||
|
|
||||||
|
**Cuándo usarlo:**
|
||||||
|
- Visualización en `<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 | ~8–12 KB |
|
||||||
|
| WebP | ~5–8 KB |
|
||||||
|
| SVG | ~3–6 KB |
|
||||||
|
|
||||||
|
> Los tamaños varían según la complejidad del contenido (los QR con más datos tienen más módulos).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recomendación por Caso de Uso
|
||||||
|
|
||||||
|
| Escenario | Formato recomendado |
|
||||||
|
|---|---|
|
||||||
|
| Mostrar en sitio/app | **WebP** |
|
||||||
|
| Enviar por e-mail | **PNG** |
|
||||||
|
| Impresión gráfica / diseño | **SVG** |
|
||||||
|
| Guardar en la nube | **WebP** |
|
||||||
|
| Máxima compatibilidad | **PNG** |
|
||||||
|
| Sin preocupación por tamaño | **PNG** |
|
||||||
129
Content/DevTutoriais/formatos-de-imagem.pt-BR.md
Normal file
129
Content/DevTutoriais/formatos-de-imagem.pt-BR.md
Normal 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 **25–35% menos tamanho de arquivo**.
|
||||||
|
|
||||||
|
**Vantagens:**
|
||||||
|
- Carregamento mais rápido em páginas web
|
||||||
|
- Menor uso de bandwidth em APIs com alto volume
|
||||||
|
- Suporte nativo em todos os browsers modernos (Chrome, Safari 14+, Firefox, Edge)
|
||||||
|
- Menor consumo de armazenamento em buckets (S3, GCS, etc.)
|
||||||
|
|
||||||
|
**Quando usar:**
|
||||||
|
- Exibição em `<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 | ~8–12 KB |
|
||||||
|
| WebP | ~5–8 KB |
|
||||||
|
| SVG | ~3–6 KB |
|
||||||
|
|
||||||
|
> Os tamanhos variam conforme a complexidade do conteúdo (QR codes com mais dados têm mais módulos).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recomendação por Caso de Uso
|
||||||
|
|
||||||
|
| Cenário | Formato recomendado |
|
||||||
|
|---|---|
|
||||||
|
| Exibir em site/app | **WebP** |
|
||||||
|
| Enviar por e-mail | **PNG** |
|
||||||
|
| Impressão gráfica / design | **SVG** |
|
||||||
|
| Armazenar em cloud | **WebP** |
|
||||||
|
| Máxima compatibilidade | **PNG** |
|
||||||
|
| Sem preocupação com tamanho | **PNG** |
|
||||||
186
Content/DevTutoriais/gerando-qrcodes-pela-api.es-PY.md
Normal file
186
Content/DevTutoriais/gerando-qrcodes-pela-api.es-PY.md
Normal 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 (100–2000) |
|
||||||
|
| `primaryColor` | string | `"#000000"` | Color de los módulos (hex) |
|
||||||
|
| `backgroundColor` | string | `"#FFFFFF"` | Color de fondo (hex) |
|
||||||
|
| `outputFormat` | string | `"png"` | Formato: `png`, `webp`, `svg` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `url` — Link / URL
|
||||||
|
|
||||||
|
El más simple: cualquier URL válida.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "url",
|
||||||
|
"content": "https://tuempresa.com.py/producto/123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Usá `https://` siempre que sea posible. Los QR con HTTP pueden ser bloqueados por algunos lectores modernos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `pix` — Pago Pix
|
||||||
|
|
||||||
|
El `content` debe ser la **clave Pix** del receptor. La generación produce un QR de Pix estático (sin conexión con el Banco Central — mirá el tutorial dedicado sobre Pix).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pix",
|
||||||
|
"content": "contacto@tuempresa.com.br"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Claves aceptadas: CPF/CNPJ (solo dígitos), e-mail, teléfono en formato `+5511999999999`, o clave aleatoria (UUID).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `wifi` — Red Wi-Fi
|
||||||
|
|
||||||
|
El `content` usa el formato estándar `WIFI:`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "wifi",
|
||||||
|
"content": "WIFI:S:NombreDeRed;T:WPA;P:ContraseñaDeRed;;"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parámetro | Significado | Valores |
|
||||||
|
|---|---|---|
|
||||||
|
| `S:` | SSID (nombre de la red) | texto |
|
||||||
|
| `T:` | Tipo de seguridad | `WPA`, `WEP`, `nopass` |
|
||||||
|
| `P:` | Contraseña | texto |
|
||||||
|
| `H:` | Red oculta (opcional) | `true` / `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `vcard` — Tarjeta de Visita
|
||||||
|
|
||||||
|
El `content` debe ser un vCard v3 completo:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "vcard",
|
||||||
|
"content": "BEGIN:VCARD\nVERSION:3.0\nFN:Juan Pérez\nORG:Empresa SRL\nTEL:+595981999999\nEMAIL:juan@empresa.com.py\nURL:https://empresa.com.py\nEND:VCARD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `whatsapp` — Link para WhatsApp
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "whatsapp",
|
||||||
|
"content": "https://wa.me/595981999999?text=Mba'éichapa%2C%20quiero%20más%20información"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
El número debe incluir código de país ha área, sin espacios ni símbolos. El parámetro `text` es opcional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `email` — E-mail con asunto ha cuerpo
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "email",
|
||||||
|
"content": "mailto:contacto@empresa.com.py?subject=Consulta&body=Mba'éichapa%2C%20quisiera%20saber%20más"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `sms` — SMS pre-cargado
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "sms",
|
||||||
|
"content": "sms:+595981999999?body=Mba'éichapa%2C%20quiero%20más%20información"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `texto` — Texto Libre
|
||||||
|
|
||||||
|
Cualquier string de hasta 2048 caracteres:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "texto",
|
||||||
|
"content": "Mesa 5 — Atención preferencial disponible en el mostrador central. Porã roipytyvõ!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Respuesta Exitosa
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"qrCodeBase64": "iVBORw0KGgo...",
|
||||||
|
"qrId": "abc123",
|
||||||
|
"generationTimeMs": 180,
|
||||||
|
"remainingCredits": 42,
|
||||||
|
"fromCache": false,
|
||||||
|
"format": "png",
|
||||||
|
"mimeType": "image/png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Para mostrar la imagen directamente en HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<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.
|
||||||
186
Content/DevTutoriais/gerando-qrcodes-pela-api.pt-BR.md
Normal file
186
Content/DevTutoriais/gerando-qrcodes-pela-api.pt-BR.md
Normal 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 (100–2000) |
|
||||||
|
| `primaryColor` | string | `"#000000"` | Cor dos módulos (hex) |
|
||||||
|
| `backgroundColor` | string | `"#FFFFFF"` | Cor de fundo (hex) |
|
||||||
|
| `outputFormat` | string | `"png"` | Formato: `png`, `webp`, `svg` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `url` — Link / URL
|
||||||
|
|
||||||
|
O mais simples: qualquer URL válida.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "url",
|
||||||
|
"content": "https://seusite.com.br/produto/123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use `https://` sempre que possível. QR codes com HTTP podem ser bloqueados por alguns leitores modernos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `pix` — Pagamento Pix
|
||||||
|
|
||||||
|
O `content` deve ser a **chave Pix** do recebedor. A geração produz um QR de Pix estático (sem conexão com o Banco Central — veja o tutorial dedicado sobre Pix).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "pix",
|
||||||
|
"content": "contato@suaempresa.com.br"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaves aceitas: CPF/CNPJ (apenas dígitos), e-mail, telefone no formato `+5511999999999`, ou chave aleatória (UUID).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `wifi` — Rede Wi-Fi
|
||||||
|
|
||||||
|
O `content` usa o formato padrão `WIFI:`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "wifi",
|
||||||
|
"content": "WIFI:S:NomeDaRede;T:WPA;P:SenhaDaRede;;"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parâmetro | Significado | Valores |
|
||||||
|
|---|---|---|
|
||||||
|
| `S:` | SSID (nome da rede) | texto |
|
||||||
|
| `T:` | Tipo de segurança | `WPA`, `WEP`, `nopass` |
|
||||||
|
| `P:` | Senha | texto |
|
||||||
|
| `H:` | Rede oculta (opcional) | `true` / `false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `vcard` — Cartão de Visita
|
||||||
|
|
||||||
|
O `content` deve ser um vCard v3 completo:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "vcard",
|
||||||
|
"content": "BEGIN:VCARD\nVERSION:3.0\nFN:João Silva\nORG:Empresa Ltda\nTEL:+5511999999999\nEMAIL:joao@empresa.com\nURL:https://empresa.com\nEND:VCARD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `whatsapp` — Link para WhatsApp
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "whatsapp",
|
||||||
|
"content": "https://wa.me/5511999999999?text=Olá%2C%20gostaria%20de%20saber%20mais"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
O número deve incluir código do país e DDD, sem espaços ou símbolos. O parâmetro `text` é opcional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `email` — E-mail com assunto e corpo
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "email",
|
||||||
|
"content": "mailto:contato@empresa.com?subject=Assunto&body=Mensagem%20inicial"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `sms` — SMS pré-preenchido
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "sms",
|
||||||
|
"content": "sms:+5511999999999?body=Olá%2C%20quero%20mais%20informações"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipo `texto` — Texto Livre
|
||||||
|
|
||||||
|
Qualquer string de até 2048 caracteres:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "texto",
|
||||||
|
"content": "Mesa 12 — Atendimento preferencial disponível no balcão central."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resposta de Sucesso
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"qrCodeBase64": "iVBORw0KGgo...",
|
||||||
|
"qrId": "abc123",
|
||||||
|
"generationTimeMs": 180,
|
||||||
|
"remainingCredits": 42,
|
||||||
|
"fromCache": false,
|
||||||
|
"format": "png",
|
||||||
|
"mimeType": "image/png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Para exibir a imagem diretamente no HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<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.
|
||||||
142
Content/DevTutoriais/qr-code-pix-estatico.es-PY.md
Normal file
142
Content/DevTutoriais/qr-code-pix-estatico.es-PY.md
Normal 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.
|
||||||
142
Content/DevTutoriais/qr-code-pix-estatico.pt-BR.md
Normal file
142
Content/DevTutoriais/qr-code-pix-estatico.pt-BR.md
Normal 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**.
|
||||||
63
Controllers/DevTutoriaisController.cs
Normal file
63
Controllers/DevTutoriaisController.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
Controllers/DeveloperController.cs
Normal file
148
Controllers/DeveloperController.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
|
||||||
{
|
|
||||||
deviceId = Guid.NewGuid().ToString("N");
|
|
||||||
Response.Cookies.Append("_qr_device_id", deviceId, new CookieOptions
|
|
||||||
{
|
|
||||||
Expires = DateTime.UtcNow.AddYears(1),
|
|
||||||
HttpOnly = true, // Protege contra limpeza via JS simples
|
|
||||||
Secure = true,
|
|
||||||
SameSite = SameSiteMode.Strict
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar Limite (1 por dia)
|
if (!result.Success)
|
||||||
var canGenerate = await _userService.CheckAnonymousLimitAsync(ipAddress, deviceId);
|
{
|
||||||
if (!canGenerate)
|
if (result.ErrorCode == "LIMIT_REACHED")
|
||||||
{
|
{
|
||||||
return StatusCode(429, new
|
return StatusCode(429, new
|
||||||
{
|
{
|
||||||
error = "Limite diário atingido (1 QR Grátis). Cadastre-se para ganhar mais 5!",
|
error = result.Message,
|
||||||
upgradeUrl = "/Account/Login"
|
upgradeUrl = "/Account/Login"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (result.ErrorCode == "INSUFFICIENT_CREDITS")
|
||||||
// Gerar QR
|
|
||||||
request.IsPremium = false;
|
|
||||||
request.OptimizeForSpeed = true;
|
|
||||||
var result = await _qrService.GenerateRapidAsync(request);
|
|
||||||
|
|
||||||
if (result.Success)
|
|
||||||
{
|
{
|
||||||
// Registrar uso anônimo para bloqueio futuro
|
return StatusCode(402, new {
|
||||||
await _userService.RegisterAnonymousUsageAsync(ipAddress, deviceId, result.QRId);
|
success = false,
|
||||||
|
error = result.Message,
|
||||||
|
redirectUrl = "/Pagamento/SelecaoPlano"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return BadRequest(new { success = false, error = result.Message });
|
||||||
return Ok(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// Gerenciar Cookie de DeviceID para anônimos
|
||||||
// 2. FLUXO DE USUÁRIO LOGADO (CRÉDITOS)
|
if (!context.IsAuthenticated && string.IsNullOrEmpty(context.DeviceId))
|
||||||
// ---------------------------------------------------------
|
|
||||||
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.");
|
var newDeviceId = Guid.NewGuid().ToString("N");
|
||||||
return Ok(new QRGenerationResult
|
Response.Cookies.Append("_qr_device_id", newDeviceId, new CookieOptions
|
||||||
{
|
{
|
||||||
Success = true,
|
Expires = DateTime.UtcNow.AddYears(1),
|
||||||
QRCodeBase64 = duplicate.QRCodeBase64,
|
HttpOnly = true,
|
||||||
QRId = duplicate.Id,
|
Secure = true,
|
||||||
FromCache = true,
|
SameSite = SameSiteMode.Strict
|
||||||
RemainingQRs = user.Credits,
|
|
||||||
Message = "Recuperado do histórico (sem custo)"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// B. Verificar Cota Gratuita (5 Primeiros)
|
return Ok(new
|
||||||
if (user.FreeQRsUsed < 5)
|
|
||||||
{
|
{
|
||||||
if (await _userService.IncrementFreeUsageAsync(userId))
|
success = true,
|
||||||
{
|
QRCodeBase64 = result.QRCodeBase64,
|
||||||
return await ProcessLoggedGeneration(request, userId, true, contentHash, 0); // Cost 0
|
QRId = result.QRId,
|
||||||
}
|
FromCache = result.FromCache,
|
||||||
}
|
RemainingQRs = context.IsAuthenticated ? result.RemainingCredits : (result.RemainingFreeQRs > 0 ? result.RemainingFreeQRs : 0),
|
||||||
|
Message = result.Message,
|
||||||
// C. Verificar Créditos Pagos
|
GenerationTimeMs = result.GenerationTimeMs
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
151
Controllers/QRManagerController.cs
Normal file
151
Controllers/QRManagerController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,14 +31,16 @@ 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"
|
||||||
: "pt-BR";
|
: reqPath.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) ? "es"
|
||||||
|
: "pt-BR";
|
||||||
|
|
||||||
var article = await _markdownService.GetArticleAsync(slug, culture);
|
var article = await _markdownService.GetArticleAsync(slug, culture);
|
||||||
|
|
||||||
@ -89,14 +91,16 @@ 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"
|
||||||
: "pt-BR";
|
: reqPath.StartsWith("/es/", StringComparison.OrdinalIgnoreCase) ? "es"
|
||||||
|
: "pt-BR";
|
||||||
|
|
||||||
var articles = await _markdownService.GetAllArticlesAsync(culture);
|
var articles = await _markdownService.GetAllArticlesAsync(culture);
|
||||||
|
|
||||||
|
|||||||
162
Filters/ApiKeyAuthorizeAttribute.cs
Normal file
162
Filters/ApiKeyAuthorizeAttribute.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Middleware/ApiSecurityHeadersMiddleware.cs
Normal file
50
Middleware/ApiSecurityHeadersMiddleware.cs
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
36
Models/ApiPlanTier.cs
Normal 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
36
Models/ApiSubscription.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Models/DTOs/QRResponseDto.cs
Normal file
22
Models/DTOs/QRResponseDto.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Models/DTOs/UserRequesterContext.cs
Normal file
10
Models/DTOs/UserRequesterContext.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
122
Program.cs
122
Program.cs
@ -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(
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
88
Services/ApiRateLimitService.cs
Normal file
88
Services/ApiRateLimitService.cs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
Services/IApiRateLimitService.cs
Normal file
23
Services/IApiRateLimitService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
Services/IQRBusinessManager.cs
Normal file
10
Services/IQRBusinessManager.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Services/IQuotaValidator.cs
Normal file
10
Services/IQuotaValidator.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
106
Services/QRBusinessManager.cs
Normal file
106
Services/QRBusinessManager.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(() =>
|
||||||
|
|||||||
72
Services/QuotaValidator.cs
Normal file
72
Services/QuotaValidator.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 &&
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
102
Views/DevTutoriais/Article.cshtml
Normal file
102
Views/DevTutoriais/Article.cshtml
Normal 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")
|
||||||
|
·
|
||||||
|
<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>
|
||||||
64
Views/DevTutoriais/Index.cshtml
Normal file
64
Views/DevTutoriais/Index.cshtml
Normal 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>
|
||||||
367
Views/Developer/Index.cshtml
Normal file
367
Views/Developer/Index.cshtml
Normal 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>
|
||||||
|
}
|
||||||
180
Views/Developer/Pricing.cshtml
Normal file
180
Views/Developer/Pricing.cshtml
Normal 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>
|
||||||
@ -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">
|
||||||
|
|||||||
4
Views/_ViewImports.cshtml
Normal file
4
Views/_ViewImports.cshtml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@using QRRapidoApp
|
||||||
|
@using QRRapidoApp.Models
|
||||||
|
@using QRRapidoApp.Models.ViewModels
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user