8 Commits

Author SHA1 Message Date
Felipe Coutinho
27e3ba5f0d Update version badge from 2.1.2 to 2.3.4 2026-04-05 20:33:55 -03:00
Felipe Coutinho
31485eec8f fix(csp): permitir upload de anexos para o storage externo
connect-src bloqueava fetch para o Supabase Storage desde o commit
de segurança (10afef9). Adiciona a origin do S3_ENDPOINT na política.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:47:23 +00:00
Felipe Coutinho
3be64aa8d0 fix(auth): corrigir verify e unificar tokens com prefixo opm_
- Corrige /api/auth/device/verify que rejeitava tokens criados via
  Settings (revertido de JWT para hash lookup)
- Renomeia prefixo de tokens de os_ para opm_ (OpenMonetis)
- Remove rotas JWT não utilizadas (token, refresh)
- Simplifica api-token.ts mantendo apenas hashToken e extractBearerToken

BREAKING CHANGE: tokens existentes com prefixo os_ param de funcionar.
Revogar e recriar tokens após o deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:05:03 +00:00
Felipe Coutinho
85f6dcfc22 fix(csp): permitir unsafe-eval apenas em desenvolvimento
React precisa de eval() em dev para reconstruir stack traces.
Produção continua sem unsafe-eval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:21:03 +00:00
Felipe Coutinho
df996df93d fix(segurança): substituir xlsx por exceljs (CVEs sem patch no npm)
xlsx@0.18.5 tem Prototype Pollution e ReDoS sem versão corrigida no
npm. Migrado para exceljs@4.4.0 nos 4 pontos de uso: parser de
importação, geração de template, exportação de lançamentos e
exportação de relatório de categorias.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:12:04 +00:00
Felipe Coutinho
10afef9fec fix(segurança): corrigir 10 vulnerabilidades do relatório de segurança
- tokens: remover aceite de expiresAt NULL e forçar TTL de 1 ano
- tokens: corrigir refresh que invalidava access token anterior
- xlsx: desabilitar parsing de fórmulas (CVE-2024-44294)
- csp: expandir Content-Security-Policy com origens explícitas
- headers: adicionar Referrer-Policy e X-Permitted-Cross-Domain-Policies
- api: retornar 401 JSON em vez de redirect 302 em rotas autenticadas
- health: remover version disclosure do /api/health
- robots.txt: simplificar para não expor rotas internas
- sitemap: corrigir URL com protocolo duplicado
- criar security.txt (RFC 9116)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:47:05 +00:00
Felipe Coutinho
fd4d90a53e ci: forçar Node.js 24 nas actions do workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:52:52 +00:00
Felipe Coutinho
a24406271c chore: corrigir formatacao do package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:51:14 +00:00
26 changed files with 945 additions and 668 deletions

View File

@@ -13,6 +13,7 @@ on:
env:
DOCKER_IMAGE_NAME: openmonetis
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
quality:

View File

@@ -5,6 +5,9 @@ on:
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
release:
runs-on: ubuntu-latest

View File

@@ -7,6 +7,39 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased]
## [2.3.4] - 2026-04-05
### Corrigido
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
## [2.3.3] - 2026-04-05
### Corrigido
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
### Alterado
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
## [2.3.2] - 2026-04-04
### Segurança
- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano
- Tokens: corrigido refresh que sobrescrevia hash e invalidava access token anterior; verify agora valida JWT por assinatura
- xlsx: desabilitado parsing de fórmulas (`cellFormula: false`) para mitigar CVE-2024-44294
- CSP: expandida Content-Security-Policy com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src`
- Headers: adicionados `Referrer-Policy` e `X-Permitted-Cross-Domain-Policies`
- API: rotas autenticadas agora retornam `401 JSON` em vez de redirect `302` para clientes não autenticados
- Health: removido campo `version` da resposta do `/api/health`
- robots.txt: simplificado para não expor mapa de rotas internas
- Sitemap: corrigida URL com protocolo duplicado (`https://https://`)
- Criado `security.txt` (RFC 9116)
## [2.3.1] - 2026-04-03
### Corrigido

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.1.2-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.3.4-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)

View File

@@ -4,6 +4,8 @@ import type { NextConfig } from "next";
// Carregar variáveis de ambiente explicitamente
dotenv.config();
const isDev = process.env.NODE_ENV === "development";
const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: true,
@@ -44,7 +46,23 @@ const nextConfig: NextConfig = {
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'none';",
value: [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
"font-src 'self'",
`connect-src 'self' https://umami.felipecoutinho.com ${process.env.S3_ENDPOINT ? new URL(process.env.S3_ENDPOINT).origin : ""}`.trim(),
"frame-ancestors 'none'",
].join("; "),
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "X-Permitted-Cross-Domain-Policies",
value: "none",
},
{
key: "Permissions-Policy",

View File

@@ -1,108 +1,108 @@
{
"name": "openmonetis",
"version": "2.3.1",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "next dev --turbopack",
"db:seed": "tsx scripts/mock-data.ts",
"build": "next build",
"start": "next start",
"lint": "biome check .",
"lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio",
"docker:up": "docker compose up --build",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up:db": "docker compose up -d db",
"docker:up:d": "docker compose up --build -d",
"docker:down": "docker compose down",
"docker:down:volumes": "docker compose down -v",
"docker:logs": "docker compose logs -f",
"docker:logs:app": "docker compose logs -f app",
"docker:logs:db": "docker compose logs -f db",
"docker:restart": "docker compose restart",
"docker:rebuild": "docker compose up --build --force-recreate",
"backup": "bash scripts/backup.sh"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.65",
"@ai-sdk/google": "^3.0.55",
"@ai-sdk/openai": "^3.0.49",
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@better-auth/passkey": "^1.5.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.143",
"better-auth": "1.5.6",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "0.45.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"next": "16.2.2",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205",
"pg": "8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"recharts": "3.8.1",
"resend": "^6.10.0",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"vaul": "1.1.2",
"xlsx": "^0.18.5",
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.0",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.4.0",
"drizzle-kit": "0.31.10",
"knip": "^6.3.0",
"tailwindcss": "4.2.2",
"tsx": "4.21.0",
"typescript": "6.0.2"
}
"name": "openmonetis",
"version": "2.3.4",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "next dev --turbopack",
"db:seed": "tsx scripts/mock-data.ts",
"build": "next build",
"start": "next start",
"lint": "biome check .",
"lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio",
"docker:up": "docker compose up --build",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up:db": "docker compose up -d db",
"docker:up:d": "docker compose up --build -d",
"docker:down": "docker compose down",
"docker:down:volumes": "docker compose down -v",
"docker:logs": "docker compose logs -f",
"docker:logs:app": "docker compose logs -f app",
"docker:logs:db": "docker compose logs -f db",
"docker:restart": "docker compose restart",
"docker:rebuild": "docker compose up --build --force-recreate",
"backup": "bash scripts/backup.sh"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.65",
"@ai-sdk/google": "^3.0.55",
"@ai-sdk/openai": "^3.0.49",
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@better-auth/passkey": "^1.5.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.143",
"better-auth": "1.5.6",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "0.45.2",
"exceljs": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"next": "16.2.2",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205",
"pg": "8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"recharts": "3.8.1",
"resend": "^6.10.0",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"vaul": "1.1.2",
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.0",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.4.0",
"drizzle-kit": "0.31.10",
"knip": "^6.3.0",
"tailwindcss": "4.2.2",
"tsx": "4.21.0",
"typescript": "6.0.2"
}
}

646
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
Contact: https://github.com/felipegcoutinho/openmonetis/security/advisories
Expires: 2027-04-04T00:00:00.000Z
Preferred-Languages: pt-BR, en
Canonical: https://openmonetis.com/.well-known/security.txt

View File

@@ -1,7 +1,7 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { attachments } from "@/db/schema";
import { getUserId } from "@/shared/lib/auth/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
@@ -13,7 +13,19 @@ export async function GET(
_request: Request,
{ params }: { params: Promise<{ attachmentId: string }> },
) {
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
const [session, { attachmentId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const [row] = await db
.select({ fileKey: attachments.fileKey })

View File

@@ -1,87 +0,0 @@
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 },
);
}
// Validar refresh token
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_refresh") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 },
);
}
// Verificar se token não foi revogado
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}
// Gerar novo access token
const result = refreshAccessToken(token);
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 },
);
}
// Atualizar hash do token e último uso
await db
.update(apiTokens)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null,
expiresAt: result.expiresAt,
})
.where(eq(apiTokens.id, payload.tokenId));
return NextResponse.json({
accessToken: result.accessToken,
expiresAt: result.expiresAt.toISOString(),
});
} catch (error) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 },
);
}
}

View File

@@ -1,74 +0,0 @@
import { headers } from "next/headers";
import { connection, NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
generateTokenPair,
getTokenPrefix,
hashToken,
} from "@/shared/lib/auth/api-token";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
const createTokenSchema = z.object({
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
});
export async function POST(request: Request) {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId,
);
// Salvar hash do token no banco
await db.insert(apiTokens).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Retornar tokens (mostrados apenas uma vez)
return NextResponse.json(
{
accessToken,
refreshToken,
tokenId,
name,
expiresAt: expiresAt.toISOString(),
message:
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
}
}

View File

@@ -17,15 +17,14 @@ export async function POST(request: Request) {
);
}
// Validar token os_xxx via hash lookup
if (!token.startsWith("os_")) {
// Validar token opm_xxx via hash
if (!token.startsWith("opm_")) {
return NextResponse.json(
{ valid: false, error: "Formato de token inválido" },
{ status: 401 },
);
}
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Buscar token no banco

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { version as APP_VERSION } from "@/package.json";
import { db } from "@/shared/lib/db";
/**
@@ -20,7 +19,6 @@ export async function GET() {
{
status: "ok",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 },
@@ -33,7 +31,6 @@ export async function GET() {
{
status: "error",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message: "Database connection failed",
},

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
// Validar token opm_xxx via hash
if (!token.startsWith("opm_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
gt(apiTokens.expiresAt, new Date()),
),
});

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
);
}
// Validar token os_xxx via hash
if (!token.startsWith("os_")) {
// Validar token opm_xxx via hash
if (!token.startsWith("opm_")) {
return NextResponse.json(
{ error: "Formato de token inválido" },
{ status: 401 },
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
gt(apiTokens.expiresAt, new Date()),
),
});

View File

@@ -3,7 +3,7 @@ import {
fetchSavedInsights,
savedInsightsPeriodSchema,
} from "@/features/insights/queries";
import { getUserId } from "@/shared/lib/auth/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
@@ -25,8 +25,18 @@ export async function GET(request: Request) {
);
}
const userId = await getUserId();
const insights = await fetchSavedInsights(userId, validatedPeriod.data);
const session = await getOptionalUserSession();
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const insights = await fetchSavedInsights(
session.user.id,
validatedPeriod.data,
);
return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS,

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
import { getUserId } from "@/shared/lib/auth/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
@@ -10,7 +10,19 @@ export async function GET(
_request: Request,
{ params }: { params: Promise<{ transactionId: string }> },
) {
const [userId, { transactionId }] = await Promise.all([getUserId(), params]);
const [session, { transactionId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const attachments = await fetchTransactionAttachments(userId, transactionId);
return NextResponse.json(attachments, {

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
import { getUserId } from "@/shared/lib/auth/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
@@ -11,7 +11,19 @@ export async function GET(
{ params }: { params: Promise<{ seriesId: string }> },
) {
try {
const [userId, { seriesId }] = await Promise.all([getUserId(), params]);
const [session, { seriesId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
return NextResponse.json(anticipations, {

View File

@@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: "*",
allow: "/",
disallow: [
"/dashboard",
"/transactions",
"/accounts",
"/cards",
"/categories",
"/budgets",
"/payers",
"/notes",
"/insights",
"/calendar",
"/attachments",
"/settings",
"/reports",
"/inbox",
"/login",
"/signup",
"/api/",
],
disallow: "/api/",
},
],
};

View File

@@ -1,7 +1,7 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
: "https://openmonetis.com";
export default function sitemap(): MetadataRoute.Sitemap {

View File

@@ -33,7 +33,7 @@ interface CategoryReportExportProps {
filters: FilterState;
}
const loadXlsx = () => import("xlsx");
const loadExcelJS = () => import("exceljs");
const loadPdfDeps = async () => {
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
@@ -134,7 +134,7 @@ export function CategoryReportExport({
const exportToExcel = async () => {
try {
setIsExporting(true);
const XLSX = await loadXlsx();
const ExcelJS = await loadExcelJS();
// Build data array
const headers = [
@@ -179,20 +179,32 @@ export function CategoryReportExport({
totalsRow.push(formatCurrency(data.grandTotal));
rows.push(totalsRow);
// Create worksheet
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
// Create workbook and worksheet
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet("Relatório de Categorias");
ws.addRows([headers, ...rows]);
// Set column widths
ws["!cols"] = [
{ wch: 20 }, // Category
...data.periods.map(() => ({ wch: 15 })), // Periods
{ wch: 15 }, // Total
];
ws.getColumn(1).width = 20;
for (let i = 0; i < data.periods.length; i++) {
ws.getColumn(i + 2).width = 15;
}
ws.getColumn(data.periods.length + 2).width = 15;
// Create workbook and download
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Relatório de Categorias");
XLSX.writeFile(wb, getFileName("xlsx"));
// Download
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = getFileName("xlsx");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Relatório exportado em Excel com sucesso!");
} catch (error) {

View File

@@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({
});
function generateSecureToken(): string {
const prefix = "os";
const prefix = "opm";
const randomPart = randomBytes(32).toString("base64url");
return `${prefix}_${randomPart}`;
}
@@ -649,7 +649,7 @@ export async function createApiTokenAction(
name: validated.name,
tokenHash,
tokenPrefix,
expiresAt: null, // No expiration for now
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 ano
})
.returning({ id: apiTokens.id });

View File

@@ -45,10 +45,10 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
reader.readAsText(file, "windows-1252");
} else {
const reader = new FileReader();
reader.onload = (e) => {
reader.onload = async (e) => {
try {
const buffer = e.target?.result as ArrayBuffer;
const statement = parseXls(buffer);
const statement = await parseXls(buffer);
onParsed(statement);
} catch (err) {
setError(
@@ -62,8 +62,8 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
}
};
const handleDownloadTemplate = () => {
const bytes = generateXlsTemplate();
const handleDownloadTemplate = async () => {
const bytes = await generateXlsTemplate();
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});

View File

@@ -32,7 +32,7 @@ interface LancamentosExportProps {
exportContext?: TransactionsExportContext;
}
const loadXlsx = () => import("xlsx");
const loadExcelJS = () => import("exceljs");
const loadPdfDeps = async () => {
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
@@ -158,7 +158,7 @@ export function TransactionsExport({
try {
setIsExporting(true);
const transactions = await loadTransactions();
const XLSX = await loadXlsx();
const ExcelJS = await loadExcelJS();
const headers = [
"Data",
@@ -188,23 +188,28 @@ export function TransactionsExport({
rows.push(row);
});
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet("Lançamentos");
ws["!cols"] = [
{ wch: 12 }, // Data
{ wch: 42 }, // Nome
{ wch: 15 }, // Tipo
{ wch: 15 }, // Condição
{ wch: 20 }, // Pagamento
{ wch: 15 }, // Valor
{ wch: 20 }, // Category
{ wch: 20 }, // Conta/Cartão
{ wch: 20 }, // Payer
];
ws.addRows([headers, ...rows]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
XLSX.writeFile(wb, getFileName("xlsx"));
const colWidths = [12, 42, 15, 15, 20, 15, 20, 20, 20];
colWidths.forEach((w, i) => {
ws.getColumn(i + 1).width = w;
});
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = getFileName("xlsx");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Lançamentos exportados em Excel com sucesso!");
} catch (error) {

View File

@@ -1,149 +1,5 @@
import crypto from "node:crypto";
function getJwtSecret(): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error(
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
);
}
return secret;
}
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
// ============================================================================
// TYPES
// ============================================================================
export interface JwtPayload {
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
}
// ============================================================================
// JWT UTILITIES
// ============================================================================
/**
* Base64URL encode a string
*/
function base64UrlEncode(str: string): string {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Base64URL decode a string
*/
function base64UrlDecode(str: string): string {
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
}
/**
* Create HMAC-SHA256 signature
*/
function createSignature(data: string): string {
return crypto
.createHmac("sha256", getJwtSecret())
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Create a JWT token
*/
export function createJwt(
payload: Omit<JwtPayload, "iat" | "exp">,
expiresIn: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
return `${headerEncoded}.${payloadEncoded}.${signature}`;
}
/**
* Verify and decode a JWT token
* @returns The decoded payload or null if invalid
*/
export function verifyJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(
`${headerEncoded}.${payloadEncoded}`,
);
// Constant-time comparison to prevent timing attacks
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
return payload;
} catch {
return null;
}
}
// ============================================================================
// TOKEN GENERATION
// ============================================================================
/**
* Generate a random token ID
*/
export function generateTokenId(): string {
return crypto.randomUUID();
}
/**
* Hash a token using SHA-256
*/
@@ -151,74 +7,6 @@ export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Get the display prefix of a token (first 8 chars after prefix)
*/
export function getTokenPrefix(token: string): string {
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
}
/**
* Generate a complete token pair (access + refresh)
*/
export function generateTokenPair(
userId: string,
deviceId?: string,
): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY,
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY,
);
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
}
/**
* Refresh an access token using a refresh token
*/
export function refreshAccessToken(
refreshToken: string,
): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
if (!payload || payload.type !== "api_refresh") {
return null;
}
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{
sub: payload.sub,
type: "api_access",
tokenId: payload.tokenId,
deviceId: payload.deviceId,
},
ACCESS_TOKEN_EXPIRY,
);
return { accessToken, expiresAt };
}
// ============================================================================
// VALIDATION HELPERS
// ============================================================================
/**
* Extract bearer token from Authorization header
*/

View File

@@ -1,15 +1,34 @@
import * as XLSX from "xlsx";
import ExcelJS from "exceljs";
import type {
ImportedTransaction,
ImportStatement,
} from "@/shared/lib/import/types";
/**
* Converte serial number do Excel (1900 date system) para ano/mês/dia.
* Excel trata 1900 como bissexto (serial 60 = 29/02/1900 inexistente).
*/
function excelSerialToDate(
serial: number,
): { y: number; m: number; d: number } | null {
if (serial < 1) return null;
let adjusted = serial;
if (serial > 60) adjusted--;
const baseDate = new Date(1899, 11, 31);
const date = new Date(baseDate.getTime() + adjusted * 86400000);
return {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate(),
};
}
function parseDateValue(value: unknown): string | null {
if (value == null || value === "") return null;
// Excel date serial number
if (typeof value === "number") {
const date = XLSX.SSF.parse_date_code(value);
const date = excelSerialToDate(value);
if (!date) return null;
const y = date.y;
const m = String(date.m).padStart(2, "0");
@@ -17,6 +36,14 @@ function parseDateValue(value: unknown): string | null {
return `${y}-${m}-${d}`;
}
// ExcelJS pode retornar Date objects
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
const str = String(value).trim();
// DD/MM/YYYY
@@ -43,52 +70,37 @@ function parseAmountValue(value: unknown): number | null {
return Number.isNaN(num) ? null : Math.abs(num);
}
export function parseXls(buffer: ArrayBuffer): ImportStatement {
const workbook = XLSX.read(new Uint8Array(buffer), {
type: "array",
cellDates: false,
});
export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer);
if (!workbook.SheetNames.length) {
if (workbook.worksheets.length === 0) {
throw new Error("Arquivo sem abas.");
}
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const sheet = workbook.worksheets[0];
if (!sheet) {
throw new Error(`Aba "${sheetName}" não encontrada.`);
}
const range = sheet["!ref"];
if (!range) {
throw new Error("Planilha vazia (sem intervalo de células).");
}
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: "",
});
if (rows.length < 2) {
if (!sheet || sheet.rowCount < 2) {
throw new Error(
`Planilha vazia ou sem dados (${rows.length} linha(s) encontrada(s)).`,
`Planilha vazia ou sem dados (${sheet?.rowCount ?? 0} linha(s) encontrada(s)).`,
);
}
const transactions: ImportedTransaction[] = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
if (!row || row.every((cell) => cell == null || cell === "")) continue;
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // skip header
const date = parseDateValue(row[0]);
const description = row[1] != null ? String(row[1]).trim() : "";
const amount = parseAmountValue(row[2]);
const typeRaw = row[3] != null ? String(row[3]).toLowerCase().trim() : "";
// ExcelJS row.values é 1-indexed (values[0] é undefined)
const values = row.values as unknown[];
const date = parseDateValue(values[1]);
const description = values[2] != null ? String(values[2]).trim() : "";
const amount = parseAmountValue(values[3]);
const typeRaw =
values[4] != null ? String(values[4]).toLowerCase().trim() : "";
const transactionType = typeRaw === "receita" ? "income" : "expense";
if (!date || !description || amount === null || amount <= 0) continue;
if (!date || !description || amount === null || amount <= 0) return;
transactions.push({
externalId: null,
@@ -97,7 +109,7 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
description,
transactionType,
});
}
});
if (transactions.length === 0) {
throw new Error("Nenhuma transação válida encontrada na planilha.");
@@ -115,31 +127,31 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
};
}
export function generateXlsTemplate(): ArrayBuffer {
const wb = XLSX.utils.book_new();
export async function generateXlsTemplate(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet("Lançamentos");
const data = [
ws.addRows([
["Data", "Descrição", "Valor", "Tipo"],
["01/03/2026", "Ingressos São Januário", 160, "despesa"],
["01/03/2026", "Salário", 3000.0, "receita"],
["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa"],
];
]);
const ws = XLSX.utils.aoa_to_sheet(data);
ws.getColumn(1).width = 14;
ws.getColumn(2).width = 32;
ws.getColumn(3).width = 12;
ws.getColumn(4).width = 10;
ws["!cols"] = [{ wch: 14 }, { wch: 32 }, { wch: 12 }, { wch: 10 }];
// Dropdown para coluna Tipo (D2:D100)
for (let i = 2; i <= 100; i++) {
ws.getCell(`D${i}`).dataValidation = {
type: "list",
allowBlank: true,
formulae: ['"despesa,receita"'],
};
}
// Dropdown para coluna Tipo (D2:D1000)
if (!ws["!dataValidations"]) ws["!dataValidations"] = [];
(ws["!dataValidations"] as object[]).push({
type: "list",
sqref: "D2:D1000",
formula1: '"despesa,receita"',
showDropDown: false,
});
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
const raw = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as number[];
return new Uint8Array(raw).buffer as ArrayBuffer;
const buffer = await workbook.xlsx.writeBuffer();
return buffer as ArrayBuffer;
}